From 4d9829d638f3daaaa782a849fe628bfe6086361f Mon Sep 17 00:00:00 2001 From: Shashank Varma Date: Tue, 8 Sep 2026 07:39:31 -0500 Subject: [PATCH 001/238] docs: fix automations note to reflect self-hosted deployments (#3201) Signed-off-by: Shashank Varma <324153016+shashankvarma499@users.noreply.github.com> --- docs/docs/usage-guide/automations_and_usage.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/docs/usage-guide/automations_and_usage.md b/docs/docs/usage-guide/automations_and_usage.md index cadd1830e2..bed8ee4d8a 100644 --- a/docs/docs/usage-guide/automations_and_usage.md +++ b/docs/docs/usage-guide/automations_and_usage.md @@ -93,8 +93,7 @@ When this parameter is set to `true`, PR-Agent will not run any automatic tools ### GitHub App !!! note "Configurations for PR-Agent" - PR-Agent for GitHub is an App, hosted by Codium. So all the instructions below are relevant for PR-Agent users. - Same goes for [GitLab webhook](#gitlab-webhook) and [BitBucket App](#bitbucket-app) sections. + These settings apply to self-hosted GitHub App, GitLab webhook, and Bitbucket App deployments. #### GitHub app automatic tools when a new PR is opened From 349d1061c3cf60c92547d56ca58502bf86da3dcc Mon Sep 17 00:00:00 2001 From: Asara Khatun Date: Tue, 8 Sep 2026 19:47:50 +0530 Subject: [PATCH 002/238] chore: remove dead bandit configuration (#3203) --- .pre-commit-config.yaml | 8 -------- pyproject.toml | 4 ---- 2 files changed, 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d6f90b468..78b059fe55 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,11 +41,3 @@ repos: language: system types_or: [python, pyi] args: [--fix, --show-fixes] - - # - repo: https://github.com/PyCQA/bandit - # rev: 1.7.10 - # hooks: - # - id: bandit - # args: [ - # "-c", "pyproject.toml", - # ] diff --git a/pyproject.toml b/pyproject.toml index 6fba1427be..c9833cc753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,10 +177,6 @@ lint.ignore = [ [tool.ruff.lint.per-file-ignores] # Ignore import-position and unused-import in __init__.py (re-export modules). "__init__.py" = ["E402", "F401"] -[tool.bandit] -exclude_dirs = ["tests"] -skips = ["B101"] -tests = [] [tool.pytest.ini_options] asyncio_mode = "auto" From b11a1db1737c833fc1900c060caa86248580ef8e Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:03:12 +0545 Subject: [PATCH 003/238] TYP: ship a py.typed marker for PEP 561 compliance (#3205) Co-authored-by: Utsab --- pr_agent/py.typed | 0 pyproject.toml | 3 +++ 2 files changed, 3 insertions(+) create mode 100644 pr_agent/py.typed diff --git a/pr_agent/py.typed b/pr_agent/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pyproject.toml b/pyproject.toml index c9833cc753..e0bef92ffa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,9 @@ dependencies = [ [tool.setuptools] include-package-data = true +[tool.setuptools.package-data] +pr_agent = ["py.typed"] + [tool.setuptools.packages.find] where = ["."] include = [ From 11c1442f11961b93056dbe206746a4fd6d56969b Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:39:06 +0800 Subject: [PATCH 004/238] chore(deps): update yanked Python dependencies (#3211) --- uv.lock | 186 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/uv.lock b/uv.lock index c48de07cc6..b789db90f8 100644 --- a/uv.lock +++ b/uv.lock @@ -405,63 +405,63 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" }, - { url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" }, - { url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" }, - { url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" }, - { url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" }, - { url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" }, - { url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" }, - { url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" }, - { url = "https://files.pythonhosted.org/packages/11/49/fe5a8572a70cd9cba79f80af9388ac8c5c914ed4459b956f940244e499a5/charset_normalizer-3.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:057f8609f7341618c98e5aa9a6109fa116acff2a658497d47ab3325b5e8f2b08", size = 317424, upload-time = "2026-07-06T15:26:32.23Z" }, - { url = "https://files.pythonhosted.org/packages/e5/59/d71c96616b6825425a876f79f38fa440db30b32cc1166179a839f6259150/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c591f9a82adc5b89a039b90df74e43de2b9177fb46771172bed7b80722a70db0", size = 214723, upload-time = "2026-07-06T15:26:33.635Z" }, - { url = "https://files.pythonhosted.org/packages/c6/35/dc9eeb297f19b7b6ada39709ccb74937e6c51f0947958ae601a977cedd5d/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b56f449132d9adefe55b87635d05177a914ed5d070438a74725e1d77a280002", size = 236200, upload-time = "2026-07-06T15:26:34.99Z" }, - { url = "https://files.pythonhosted.org/packages/0a/37/6775fe852b4acad8bf7e0575fbe8aa9f41b546e33251acbded3c04a6b0d9/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1004c3b5831a301dadfb9e916f38e78e2ff3e08db24a1ad7c354db8ee3dea9c3", size = 231740, upload-time = "2026-07-06T15:26:36.498Z" }, - { url = "https://files.pythonhosted.org/packages/e4/28/1bcc3f5f3bac81532384adcfcdd9362c7f46a188a19deacc1ddaf7bdaa00/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60883e22821d17c9e5b4f3ca1ef8074f766e3db28791f851b665929c515635c0", size = 222888, upload-time = "2026-07-06T15:26:38.161Z" }, - { url = "https://files.pythonhosted.org/packages/47/81/9f3993ca62ef090c58059da641e49e3129e74700a6a3beb58436cdb8d4b9/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:21e4dbb942c8a6342e2685f232dd2a7bc73465697bd26ead4f118271d28be383", size = 207649, upload-time = "2026-07-06T15:26:39.628Z" }, - { url = "https://files.pythonhosted.org/packages/11/f4/679f636bcbdc2d53d06b1f4039be310450dca95a9f76bbf22f09985556e8/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84bcae14c65e645ca66b661339183d32b8c846a17c96e3e81ab3d346e1c498d4", size = 218908, upload-time = "2026-07-06T15:26:40.911Z" }, - { url = "https://files.pythonhosted.org/packages/7e/44/96e8c81867ba8a45ff893c8e7474c2d6b9633f7aa663da7901d040214d3e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4e4d44b8287aa13a25e16e29393d494b0643b24894f7c8266c6f6788dd36337", size = 217096, upload-time = "2026-07-06T15:26:42.148Z" }, - { url = "https://files.pythonhosted.org/packages/a5/bf/4d53f04f29bdb22601701f4f9f4d038edfb27976c296fcb7400c02736a6e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f68545d1b267dbfafd5d253b6d1cb161562c4e61ab25b5c4cdb7d9e5923e441e", size = 209355, upload-time = "2026-07-06T15:26:43.557Z" }, - { url = "https://files.pythonhosted.org/packages/ad/60/92b3f630798d777fa880ad289a3f9f2fc663e4b4beb24783c53318820254/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1936e48214adea74922a20c8ab41b1393ae27cc9e329eb1f0b937d3416824f36", size = 224732, upload-time = "2026-07-06T15:26:44.892Z" }, - { url = "https://files.pythonhosted.org/packages/bd/85/eafa0a3c7bb6fe9f02f4c7901f02071933cac85ee634197e17280818c6de/charset_normalizer-3.4.8-cp313-cp313-win32.whl", hash = "sha256:1f8e3521860187d597f3867d8466da225b9179ea2833bb26de1bb026144d07c3", size = 150358, upload-time = "2026-07-06T15:26:46.153Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8c/879fafff7b47bb1166d289f2d2472cb31b9922f9f4ca1f392edf85ec16be/charset_normalizer-3.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:8b654b6f52a0a9a6be38e88f3e1dc68f1093ebeb2abbadafc7c82da0786a34be", size = 161685, upload-time = "2026-07-06T15:26:47.423Z" }, - { url = "https://files.pythonhosted.org/packages/c3/46/b57c7e778a7b578f28d35fd38544687d4f8d9c019585eebc5ad936073fad/charset_normalizer-3.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:d2d5a250ee26e29468b7607d97479221b069fa8aaf6f929ac84ec0e962e15154", size = 152333, upload-time = "2026-07-06T15:26:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/1c/bc/0a8540b8cd494951cca1428606373942803f5ffcec40fe798f819c5a8adb/charset_normalizer-3.4.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:77e993ecf65f21ab1f82266ff5e84a7de2c879e7d9b8bc006009df83f22a1d5e", size = 316993, upload-time = "2026-07-06T15:26:49.962Z" }, - { url = "https://files.pythonhosted.org/packages/0e/99/a0868f0a1f0a045fd374d1f2cf7042d8ad5d7fb4dd1f4ac7365e319f7e32/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:524939917f17f6de502dfda30b472550965740d7f126659d4c4f8dd1569cce22", size = 215638, upload-time = "2026-07-06T15:26:51.338Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/43c4d09a09b5557cc5fe1d87c9d96f86a3942aec0517d2b5408cef87ca75/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4508989ba8e2ce43ef989453d18188b261546e8188cbdd4ef451fb9e4c3b467", size = 236456, upload-time = "2026-07-06T15:26:52.531Z" }, - { url = "https://files.pythonhosted.org/packages/e2/67/492ca98b3ab785b736b5da10c1bc233e1c8fec6c0cdb29b482c38bfc52a2/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e44127f7d11eee4548ad2cdf1f4e1b6eaaddd5cb92d15ad65f6ecc9bcf403ab", size = 232253, upload-time = "2026-07-06T15:26:53.838Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/1e6eff58c14f1aace1e26d80defbeaea2d35e075dbe4b611111ee4b47fa8/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb90317359f7e67bb6df615999a95e0980877468e617ddce8b6c2f8e7fe60d95", size = 222886, upload-time = "2026-07-06T15:26:55.009Z" }, - { url = "https://files.pythonhosted.org/packages/40/7a/90056a5326b0c4b9a3f924d337729c344c11542e5bc7191e50410db61587/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:35d9e7a9c960520ae89d1f4e305d1c047a74dea2e0f73a0e84f879356c2e8776", size = 206482, upload-time = "2026-07-06T15:26:56.306Z" }, - { url = "https://files.pythonhosted.org/packages/18/ff/94761d31a33878dbb5008ddbd918615061fcf5c0a612aa3075450e60f628/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e322b054c7ff886f78feab7360736bb45de2e18cf4a0ee84e8fc5a08d53a19", size = 218929, upload-time = "2026-07-06T15:26:57.422Z" }, - { url = "https://files.pythonhosted.org/packages/2b/dc/00b9675acd7c4b926b9102ee3f0d1a570ce943901be73b87485001393fe1/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c0086d97094363556206dc3bcf43f7edcfc043ea7a568a46f45efea74858bd1", size = 218069, upload-time = "2026-07-06T15:26:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/04/11/94ada5a0482ee4bf688d04be4c7d6fd945d37370d04a95671040dfe2b416/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0752c849b51198267df2aba013c4de3a2955bd014a4fd70828809946c1acbc0c", size = 207146, upload-time = "2026-07-06T15:27:00.058Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f7/246bd36762207ab4752cd436b64e5d81a1668b15ddea7b5b2d0e8545e727/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a4707e09eca11e81ece4fced600c5a0a801f568b962244f6f517bc274745fc9", size = 224896, upload-time = "2026-07-06T15:27:01.599Z" }, - { url = "https://files.pythonhosted.org/packages/6f/f7/3510622d1fbe13b0ebf827c475e40a27e2be427140d792878b63ab6425cc/charset_normalizer-3.4.8-cp314-cp314-win32.whl", hash = "sha256:8ea67f427c073ae3da0923aa55f3715131fa613a61a7f2f8d762bde75eaf00ae", size = 150851, upload-time = "2026-07-06T15:27:02.964Z" }, - { url = "https://files.pythonhosted.org/packages/32/2b/9ce65dd21672b55cf800cca5f4433afa1586fda1d78731067ec9ec544c62/charset_normalizer-3.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ff71018850863362e5c7533769d0a9f77715c31af1502d523630ce822922f5c9", size = 162549, upload-time = "2026-07-06T15:27:04.249Z" }, - { url = "https://files.pythonhosted.org/packages/2f/34/9a5967eed666a88f31a0866884606d9ec3c2cd6091e2ccd7e0b4c4176c35/charset_normalizer-3.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:44464e66f4da2f21dea7145c7693f9f60717ca4794a954dea5bf8c2c932678bd", size = 153079, upload-time = "2026-07-06T15:27:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/02/4f/aa44cc81d8987f105352c74c0bf919007f8b80e9880d28bcf0393c1a816e/charset_normalizer-3.4.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50a0c2e58ad2c203adb616fef28941b7e13716adbc25e0dfaeec29f5afe6382f", size = 338586, upload-time = "2026-07-06T15:27:06.86Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2b/b0392e2b235c08ff0623d905c2ee8ac820620544043c1ce92ce0b3d64c55/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1e589fdb95c76f08288bbb346230cdd8994db74903db6637b380f7b5fc9336", size = 222764, upload-time = "2026-07-06T15:27:08.23Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/7d466879190731f5559662c22232646f2ae2dace2323c3e5aefcf78d458a/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3d7c887444c5a7ef0d68d358d81e758a850bc626f8e639e2ca5667153272b20", size = 241331, upload-time = "2026-07-06T15:27:09.512Z" }, - { url = "https://files.pythonhosted.org/packages/70/17/8b89e797137aa28c8fb0bafbafc243246a7afe21620a13b00e37624ece1d/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:65c389b96c0cfff3a3f0458fa1c7ce554a30e23101a88a49f03997afce6a929f", size = 239323, upload-time = "2026-07-06T15:27:10.86Z" }, - { url = "https://files.pythonhosted.org/packages/d7/98/1c1940730ed22d50983be4e243c722c89d5136d6f073bd840d1128bfddcb/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593403fc47dcdf55e2987b2e3cc2e064127e2b908929f1f18b2e4a4652cbd780", size = 229964, upload-time = "2026-07-06T15:27:12.113Z" }, - { url = "https://files.pythonhosted.org/packages/53/2d/bb8e81b7ff603d3f77e9a8a5d1ad34fcabbf3c54d300c29d99fba581fa23/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:606088e9fa2b7469ab9c42d4da8e05a415622a07714edd2fcd8fed48dda4c853", size = 212405, upload-time = "2026-07-06T15:27:13.447Z" }, - { url = "https://files.pythonhosted.org/packages/ce/1f/e52a3a53b13da591bb8f21d29e63877268eadf20686b7762351d4b89062c/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0317406326fed512f42a1632ad91a96228a7616c06547666a6dd79967f1bd6ca", size = 226918, upload-time = "2026-07-06T15:27:14.89Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f9/32996d79c57189af9722fe618f46d8a86b7be035ca98887b8d0c3821f141/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b67d50ee47e5c57a0064a9cb575b963a7125819dfd1fd094d44d378fff94659b", size = 225113, upload-time = "2026-07-06T15:27:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d2/9248c18e695696513774523a794cfb8b677521ce9ad7554d301cb10a9b20/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79e402b869f270140afa5e2b0e2ac100585358d812fe3dd093d424f7a72964e0", size = 214966, upload-time = "2026-07-06T15:27:17.418Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9d/4b19432d406179a40f924691906ee5b15ac664b408971c973295192444ea/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2970b9f7ab69ec3a0423ec6b6ac718e79fbf4a282c0bc103ef88c1ef50dfa15a", size = 231699, upload-time = "2026-07-06T15:27:19.131Z" }, - { url = "https://files.pythonhosted.org/packages/be/41/bdbdf71e8c3ccff10ef3cc2bb9467a7fdb3dc94b9a406d1a3c44afd39632/charset_normalizer-3.4.8-cp314-cp314t-win32.whl", hash = "sha256:458c2972a78043b7261c9726670029f15f722e70669bcbe961153a01968f589f", size = 155333, upload-time = "2026-07-06T15:27:20.681Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f8/e05c69323bd50091ec39f5f885385b884624b0131a6885a0c83a6217ba7a/charset_normalizer-3.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:0c926329a1df7cd56d7d8349fe354460d20aefd2e394c9e159e479d018b2b359", size = 167378, upload-time = "2026-07-06T15:27:22.042Z" }, - { url = "https://files.pythonhosted.org/packages/c2/04/cbaf1a2f5e2bbf70760e774380cbf052b10849fc35e770905df31af5cf00/charset_normalizer-3.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:2232baea80a2b01783679fed4e625ccdb19a974f44c9cf0fba21a777a4c8179c", size = 157782, upload-time = "2026-07-06T15:27:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -1239,57 +1239,57 @@ wheels = [ [[package]] name = "grpcio" -version = "1.82.0" +version = "1.83.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/19/e29d3979b420b92d516ee97f0bff4fb88cbb3d724791318f50beb55db549/grpcio-1.82.0.tar.gz", hash = "sha256:bfe3247e0bb598585ce26565730970d21bccf3c9dc547dc6703a1a3c7888e8e1", size = 13184479, upload-time = "2026-07-06T04:22:54.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/e6/9ce68bc431224177b6f506cfb4896a742119c51255143069970955b6510b/grpcio-1.82.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:03cce11532da292215cd8e5f444de91982e39dfb41a916e0e0f91a5c128588ea", size = 6144680, upload-time = "2026-07-06T04:21:29.808Z" }, - { url = "https://files.pythonhosted.org/packages/59/93/00ecf28e1be7a70b4b4d148d3a551b6c9a37024a669c3650a2c374c64026/grpcio-1.82.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:01336fe31d1ea5d5154b0d803eb3584e69fb96e0e657acc87bd4d48d6abc9587", size = 11952213, upload-time = "2026-07-06T04:21:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/9be6bf4cddb5b5f39c0748cdf5639525cd4116a157c8f3802c9217e54882/grpcio-1.82.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe56f1bea709ddb2531fbd510a2dd6040e993985507c3b2bf3404507aee989b1", size = 6710770, upload-time = "2026-07-06T04:21:35.382Z" }, - { url = "https://files.pythonhosted.org/packages/00/97/62c0969e993673ebef16d526db2504f38bc4c73cf2593c28746791ab7956/grpcio-1.82.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:77a5d8fe331376c7aaa4e06e903a43bd144de4f98c6e414e13cbc5d64e5aa61e", size = 7450671, upload-time = "2026-07-06T04:21:37.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/1f/d83d9fbaaef2b6ebbf70a6e467000f13f92b4cf0b84c561c162b43128439/grpcio-1.82.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ce80ea66c803398fde9c5b1fd925920c9742da7f10f8b85acac0adac3e4d7e0", size = 6886855, upload-time = "2026-07-06T04:21:40.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/36/5ed2e28b8c5f00599c7d5e94d5f82c32cf73a6fa42d13c2900cb37c861ec/grpcio-1.82.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4665dc8c9ec30acff455e2b15c47b825f18647c555483aa1ccf670adead8adcc", size = 7501322, upload-time = "2026-07-06T04:21:43.78Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/69113709e14e24289940d6aef067b797c73c8c96f580683af7d5f09b22b0/grpcio-1.82.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4405d19ecfc7a8caa9043ff5a651e1871b54fc620f917de5dede7e6fb78e9e14", size = 8536896, upload-time = "2026-07-06T04:21:46.387Z" }, - { url = "https://files.pythonhosted.org/packages/30/f6/08bd6eb89a558f0f59dacaef747afda7c21e2c2ecf138ae2ec533dea8aeb/grpcio-1.82.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e580c19178609799660f52b1e1eb09248969be20dc44caaa4dcaa3e8450dd9", size = 7913890, upload-time = "2026-07-06T04:21:49.733Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a1/04b467e2ffc93f8e50b660bc6f47c3c8e9ba7603104a741e3d5663a261d4/grpcio-1.82.0-cp312-cp312-win32.whl", hash = "sha256:dbbd83dbaa856b387a4eba74ec3add7f594f090d3b4d390d829dd5834816aae4", size = 4240969, upload-time = "2026-07-06T04:21:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/15d3601384d5937e8d9fbe6d8e7b8171008d649744dd7b6c864932937ec9/grpcio-1.82.0-cp312-cp312-win_amd64.whl", hash = "sha256:73176d270699d76a9dc3753f25e01c19fb98f830f826a506e917d83629f1b39b", size = 5001575, upload-time = "2026-07-06T04:21:54.839Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c8/7aace81e7cd12c6ffccf0d47ac389a3f19e5280e9ab04d301d02855ecd25/grpcio-1.82.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:841bcaa24882921d41cd7a82118f9a766edcf8807c2f486e7deeb0ff5ea36181", size = 6146066, upload-time = "2026-07-06T04:21:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/10/17/b448f2265e4927188425c0b09fb943c39b50f7f53fbead644b44ccdf834b/grpcio-1.82.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d4bb7219c39c735e6573ae3c33aeafb2a4e1f1cc93a762f7899c283defaed365", size = 11948617, upload-time = "2026-07-06T04:22:00.066Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a3/fba54e50fffc9f74b1ffd5eb4e47310e6650557ef136a165f1fe7df03a65/grpcio-1.82.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:254230d2cb773a87380858d0fc74653b4761bf2013c83fe1c5d77ed8f241fc9d", size = 6714591, upload-time = "2026-07-06T04:22:04.04Z" }, - { url = "https://files.pythonhosted.org/packages/29/6f/f0ec3f1a61a746dc7037a737ba4cbe60959645311ac8542bb1ad4e72ae8b/grpcio-1.82.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:16d4f215344057eb21038319b05a0c531306230c17b075fa8461a944581006dd", size = 7454986, upload-time = "2026-07-06T04:22:06.776Z" }, - { url = "https://files.pythonhosted.org/packages/b6/11/9d6ce94465d6a7f92c413430b3e77f0879b39427a217ffb3d23585df4bb3/grpcio-1.82.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a7c283b459151a2e84df32b28c31562ab3e7f624bccffce176c5608565b0212f", size = 6888623, upload-time = "2026-07-06T04:22:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/51/1a/b887adb7abb69081c4f24bc66c3612975e87caf5f7c6aa3916bb82d42e5f/grpcio-1.82.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96a3be4d2a482ff004c036f6391f33553b36a7627138d1ca3ecc1e70d55d8af5", size = 7505067, upload-time = "2026-07-06T04:22:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/60/94/90f11c0f227ac653cff769e05c7f279da945e134c9351a1c37d475714686/grpcio-1.82.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bc481e6c7e6c8721797f2ab764092c843fd5cea06d4c7e9f4c3e3b7ecf331eb0", size = 8535382, upload-time = "2026-07-06T04:22:14.453Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3d/de8569386685213135dfbaab58e221add11858485524cf7d5987efc6d70b/grpcio-1.82.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:680f633bc788652222cb90a568ec374edcd91e1fe7715a7c248ece8e1032c2bd", size = 7910708, upload-time = "2026-07-06T04:22:17.994Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/3950b32369763818ae89ef76d52e84bd034d3a0ab46bf315669c913b32e2/grpcio-1.82.0-cp313-cp313-win32.whl", hash = "sha256:640b4715b9c206e5176e46601aa1422c47c779f642a869dfd27062e8fde13dfb", size = 4240351, upload-time = "2026-07-06T04:22:20.626Z" }, - { url = "https://files.pythonhosted.org/packages/a7/5c/c7b9a48efe973883f5707a311f87772a4c307a22ec80d6f3c851846a0a02/grpcio-1.82.0-cp313-cp313-win_amd64.whl", hash = "sha256:8523e81bd23f83e607aed28fbd8244b2be4aeb34e084fcef1bd1867709085c2a", size = 5000985, upload-time = "2026-07-06T04:22:23.365Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/abc5efe11b73eefa0183531032eef2a53f33aeced6484b0d9da559c12ed6/grpcio-1.82.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:136969867c14fcc743fa39b12d7da133cae7da4f8e0e7767b6b8b7e75a8c24fd", size = 6146898, upload-time = "2026-07-06T04:22:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e4/d01ee88ceac221436dce1584ae1f046bd44d0dffc5a92e95aa34857c4516/grpcio-1.82.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a7e246cd216662f513ae79ea3afadde6afe8a659b48a4170bae9a896e69ac254", size = 11954897, upload-time = "2026-07-06T04:22:29.114Z" }, - { url = "https://files.pythonhosted.org/packages/99/b9/2a35540ec08afc08859c35afd1a8a51e2585f39b12cf4eee68dcb1fa973d/grpcio-1.82.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:85c2a4e9e8a30d73d41e547a5101e57a1ed2093ec4b2daf6847c344adba00523", size = 6723102, upload-time = "2026-07-06T04:22:32.146Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ba/af71af59943b5d08879617a1d97495d45c53e43f3c6ef16f820c64e107f9/grpcio-1.82.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a3a7802328e0d20e6ac458ff1974a8096cfaa3ca84dea0903235f641f1d703c1", size = 7454545, upload-time = "2026-07-06T04:22:34.776Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7e/258aae12b68910140725873657469ff166f8968bdb0674e12d6452b1812c/grpcio-1.82.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9331e6b91b26cffd63fb70a545f3c2cac8dc1b434b4436f43915c23e1e22f265", size = 6889585, upload-time = "2026-07-06T04:22:37.411Z" }, - { url = "https://files.pythonhosted.org/packages/5e/04/cfada8930306b9101cfb405b33ecb3b72abce51d725f900f167ffbc3146b/grpcio-1.82.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:670ab93a0059e707de53b9e715304b9f566b6defee80e5490cdd15820ce032f6", size = 7514162, upload-time = "2026-07-06T04:22:40.076Z" }, - { url = "https://files.pythonhosted.org/packages/2e/57/955f95989338857a6d73ee838b4d9e8198d50972a70ca83ec22322396f4c/grpcio-1.82.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbfe2e321bb30b84799677d6e8469896e487b494e9e4fbc9b8cc4425fe054d44", size = 8536163, upload-time = "2026-07-06T04:22:42.801Z" }, - { url = "https://files.pythonhosted.org/packages/06/62/826da0b0f01c7ba3f7ea0185968f551290deb28968d98b1edf604c895db8/grpcio-1.82.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2472b72a55da8570e089f1ca22f34a350d86c109be2755f6ee1cca5ebbdd9b54", size = 7912573, upload-time = "2026-07-06T04:22:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b4/d8824a66ac1ac7fbcdf63806f1a8725d95426654e859afa4606070c78740/grpcio-1.82.0-cp314-cp314-win32.whl", hash = "sha256:c9feb986151c2726789599b3672c1c9faa1d0824da921dc3f33a8cb1f93e2861", size = 4321824, upload-time = "2026-07-06T04:22:48.866Z" }, - { url = "https://files.pythonhosted.org/packages/da/08/02e581cc0bbb4c7b842f85e66a945b46d5bcc5927575c1086edfbc3360e7/grpcio-1.82.0-cp314-cp314-win_amd64.whl", hash = "sha256:b1eeb0480d86965263f8c8ae7ee5360c284d22176a196aab02fce7fba8d89dd8", size = 5141112, upload-time = "2026-07-06T04:22:51.443Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/9e/a3ba13e08bbee5bf6e57597dfe4823961fd7e94c0b8afe3a4bb7dca639f3/grpcio-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:5acd14c6ddf047de62cbf8745b11103ea91abbf57d1b8edd5395ccd9fcd13abb", size = 6303170, upload-time = "2026-08-28T07:08:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ae/65ce56a2527faa17d02cba4c2231c74047ad898be339486ba87f093bfb66/grpcio-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:16138031a47b771860a16a975b53087f4fd5bbdbb2c03a188c5d90ad65d2bdae", size = 12165806, upload-time = "2026-08-28T07:08:10.309Z" }, + { url = "https://files.pythonhosted.org/packages/4e/91/40432480088a2243d360864de072ed5b78c4ebbaabd29c28918f1e1b1454/grpcio-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5ccc26715fd4defca5e129e280dd883b1737b65045ec50ffe22ce42104089519", size = 6872490, upload-time = "2026-08-28T07:08:12.355Z" }, + { url = "https://files.pythonhosted.org/packages/c8/62/3da2300c8c79fd20a78a8a4bb6251e5068d9af33bc8fd389b98fec35e8a3/grpcio-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b74f2a1d9ab1dfa3e263ef33d581613679b78d0884babf11671af26e45570ead", size = 7618367, upload-time = "2026-08-28T07:08:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/bc/19/9fc702e31a631262d7a752fa699f6022821e707fefc8bff49b1550a57729/grpcio-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72578aa07a4008f17521ef52debcc3acfd1e2c5426243bc3ffb56a38bfe610b7", size = 7040936, upload-time = "2026-08-28T07:08:15.963Z" }, + { url = "https://files.pythonhosted.org/packages/ec/56/95933cc44cba2429765fa065c951dd529e5771b119d9d2481b4646f1d6a5/grpcio-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12e1fc59c6dc26d10d9144453ddc6cbfe4cd4c31e874ed2d0132f88e685eb8b", size = 7573096, upload-time = "2026-08-28T07:08:17.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/80/af63359da06b016de48cb111f144703a10043850dafa43ae0a038907b9e8/grpcio-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4910b62f7d12197160bfb7de06d876d64dd12d43483e8292f98f49ca09b628d9", size = 8609442, upload-time = "2026-08-28T07:08:19.777Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/f0586c56bdfb8a7a2adda01e0ac2413447cde3141ab09411a5d5afdcffd3/grpcio-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e703effe3ae779925c82ac24fdb82cf4105e1096810151ed9501c5f34546b9c", size = 7984321, upload-time = "2026-08-28T07:08:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/14ec05669f9eb295801e26c2ea8c561a1b786b0e3557c2c22131165ab010/grpcio-1.83.1-cp312-cp312-win32.whl", hash = "sha256:a2aea8bd6e0a34f12cbaddb7bb70bec836818789fa5c7ab7572c6b745396a2d4", size = 4395604, upload-time = "2026-08-28T07:08:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/e9/37/8c2f7cc16089e36a3fbacaacc7a3d043912aa0d2dfae5556f6450414ea6e/grpcio-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:583bf2e8255040a4a312f9572dfe62a05271437b149550e1a536d5c47d2d1e8a", size = 5161512, upload-time = "2026-08-28T07:08:25.81Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fd/d1fc58933bf88c9209f89dc570c810f1aa57cb04b3459cf2b26f61e32112/grpcio-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:8d228e253b77865efcbdd7b5894ca882c9e0ea98c02b7d20582e61ded8dfd4b5", size = 6305628, upload-time = "2026-08-28T07:08:27.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/49/0b40bae059c619505c9b751cee6caa208e4904e290aaefa1728c4c2c67a5/grpcio-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0468b627f2987c9a77f7580030207cbd85457ffe52998beff4f0b5c38c58a72c", size = 12156839, upload-time = "2026-08-28T07:08:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/61/4b/e8c0d635da0ee5ddd9950c8d540f5dcdd0ef1854a382cc55496a487a8d31/grpcio-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6a282e81530cead60bbd752cc04950a57f224379e9821495d6a35bd5ce9b1f4", size = 6877036, upload-time = "2026-08-28T07:08:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d4/760a33f339a7dd3d5f4b3e0e9bec5472d95592a80f887b2e9dab4e41cfbc/grpcio-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:947d945f52e8ecf3cafd2bb7113502a16ccfda3e12c854443094de32d83ad432", size = 7624404, upload-time = "2026-08-28T07:08:34.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/bd798654b06fb42a92b57d1dc1b530084fa89ed442806fcd0a833a36f9b3/grpcio-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55656318d5dd387077396dffb929171ca3966e24bfead9a6c5dba9f889062cb4", size = 7042942, upload-time = "2026-08-28T07:08:36.208Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/c00f86614566dd0961825cf0f43d4f96a74371d9d95f952bcbc4b86d9a27/grpcio-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9daf5acf4fc9d5f5627229969c2580a91e511779d76e4ccdeb9f4770f05d8bc2", size = 7576937, upload-time = "2026-08-28T07:08:38.041Z" }, + { url = "https://files.pythonhosted.org/packages/b1/38/85eff43a5c89dc666a252b5c9f8e9ab03f89e11c95b6263d2933f08fdbe7/grpcio-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b94174cbca93316888f805efbeb08f1c020f7b7493d2d50cc4f6b64ebb7e8bd", size = 8608391, upload-time = "2026-08-28T07:08:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/4e/82835483e2f812494be865e7965c0d626cb9e71ab0d83a420d75aea4ad67/grpcio-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5a7210911ffe0f67b1cdc5308f9854b6d1f1b345e3e49ab7cac1ba50fa346", size = 7980060, upload-time = "2026-08-28T07:08:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/68a98bef733fef704fbcfb3957c8dba67e3e38ca7a7fea851195bc97c648/grpcio-1.83.1-cp313-cp313-win32.whl", hash = "sha256:179368d9361854616ce6f397d4716e07480129652752fcbcfc5a7260455ad6f2", size = 4395226, upload-time = "2026-08-28T07:08:44.463Z" }, + { url = "https://files.pythonhosted.org/packages/85/a0/df4de3b51d37ac8fb0320bb9668381ce2bd3b7aa990880bfc56a8a26f665/grpcio-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:2e57af456385491a76e13c4aada8c8f43a8e47051e06ea97a9dbe2a49654e6db", size = 5160273, upload-time = "2026-08-28T07:08:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/484d981d8b90c4e6abf3030bd2ed747e84d1eb192b3ec9cbb41e0b73e4bf/grpcio-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b3c87ca908296bf125f841d3e1a2225a2b39aaa8ed7a57e7ccde465ee519bab", size = 6306089, upload-time = "2026-08-28T07:08:48.379Z" }, + { url = "https://files.pythonhosted.org/packages/84/01/0afec1c92e4f292f74a44ecf75eabbf40903125b8c4df103c9868d6338da/grpcio-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c0f3f20c90e72a171917ae65706500b096a1c3eb5f162c3ce702a2e25635f132", size = 12170381, upload-time = "2026-08-28T07:08:50.653Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/e9a2383804433a0a61d6d93777ad321c7f36ac1cfdaa4c6d1a7c9ac846b7/grpcio-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81bbf35a46bf8cad2dfbb2eccc19c711befb58b288acb534bbcd0d74283202a6", size = 6883286, upload-time = "2026-08-28T07:08:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/f8ca8f76994e14c70b9a0052e82f10de497a23db450c36379c9716ebfc4d/grpcio-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:215cec07d11176507387bda4bf2751816e880f9bff8dc1ca524bfbb8ed8f2fad", size = 7624293, upload-time = "2026-08-28T07:08:55.709Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7a/4b672814b0cd0fe63bdd735379d88b165759f3144ab023ad8ec5fc4d53ac/grpcio-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abce7d43ec29cd39230fa8339de1a07643b55adc412a454850fbd875349950ff", size = 7044346, upload-time = "2026-08-28T07:08:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/d89fe60e4239ad51be333dd9cc703741d449a35064e51f8a0b5bfa755432/grpcio-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e256f95a40e3b0183a98556fb7164d24b97eeb353123ccabfcba94712b35ee2a", size = 7584187, upload-time = "2026-08-28T07:08:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/b290d7402633d9166e4dd47e6f5f74a24ce10a8340b84455896ebc349f85/grpcio-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2110059146fb0ea216e1ffddb29377b5cc2fd412a5b0a92e102616bd5edf18c2", size = 8608730, upload-time = "2026-08-28T07:09:02.592Z" }, + { url = "https://files.pythonhosted.org/packages/f5/44/fa89e44d1b5cf5b9fa71b2fd7abf506f182fd43917231a92fbf1ea326b02/grpcio-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d944d967843f8183f9f23d5916388362e5f8eeeae855bbe4354d906dc9f31b", size = 7983283, upload-time = "2026-08-28T07:09:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/9db73ed1f35ffa76124ac574bf296d06a359798dfd6b50d382f2b8a060a1/grpcio-1.83.1-cp314-cp314-win32.whl", hash = "sha256:623c87c6d4a1cb30d82c4e896f95477050f2e01b4a1f8cf91ff2b1abdf89c457", size = 4474327, upload-time = "2026-08-28T07:09:07.179Z" }, + { url = "https://files.pythonhosted.org/packages/65/22/fc9a622d885a7a37ff972a12faaef443d74e47407181da70d0ab62ab41f0/grpcio-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:47e6934ad38779271e2e7cc5f78a63a407cf3d98114c65c1fdbcd3f5a716f29b", size = 5302032, upload-time = "2026-08-28T07:09:09.285Z" }, ] [[package]] name = "grpcio-status" -version = "1.82.0" +version = "1.83.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/c3/42148fdb28747b6c12df1351dece3fd574262fa14b180eca9821428626df/grpcio_status-1.82.0.tar.gz", hash = "sha256:ea0cbc12c52a902cb33ba56fb3233dcfad53ffead08f4a659b1752d473a6a0ac", size = 13902, upload-time = "2026-07-06T04:26:47.864Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/03/dee41fe15a9657c60397c1f215622a1f146e174367bbbb67911b62ee0629/grpcio_status-1.83.1.tar.gz", hash = "sha256:c08c8d553d6ab96effae1d923839de22926f5a316a942f48a48040e047c517af", size = 13957, upload-time = "2026-08-28T07:12:25.893Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/f3/fe7e1f57f170ed6cae20eecc0dea62e9de862e68adede80a6cd54a9b4dfa/grpcio_status-1.82.0-py3-none-any.whl", hash = "sha256:d41e45164ae2c265b268aebd9d86defa292a1ba91d86a548bad1f059c4645676", size = 14634, upload-time = "2026-07-06T04:26:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/b03ec688e5c8c6ce283ec8b214d9c171e97059eb55157465f6fd6db1562c/grpcio_status-1.83.1-py3-none-any.whl", hash = "sha256:2cd328ee62ef2b3eb957dd3b75db7dadcdbb76488fdc9ab3aba1ebfbbdc324a4", size = 14636, upload-time = "2026-08-28T07:12:15.982Z" }, ] [[package]] From 5e41d9726b9af008fa0e36fde5e0ba76fab91b80 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:50:58 +0545 Subject: [PATCH 005/238] ci: run the pre-commit workflow on pull requests (#3212) Co-authored-by: Utsab --- .github/workflows/pre-commit.yml | 6 ++++-- AGENTS.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 24aa2de365..a3e0f8500d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -1,9 +1,10 @@ -# disabled. We might run it manually if needed. +# Runs every pre-commit hook (ruff, actionlint, YAML/TOML checks, ...) on +# pull requests, so the hooks are enforced in CI rather than manual-only. name: pre-commit on: workflow_dispatch: -# pull_request: + pull_request: # push: # branches: [main] @@ -13,5 +14,6 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 # SEE https://github.com/pre-commit/action - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/AGENTS.md b/AGENTS.md index 02fab8b2b8..d6368bb2d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ Ruff is the single linting tool: `pyproject.toml` configures it and the pre-comm - Keep Python lines within the 120-character limit declared in `pyproject.toml`. - `pyproject.toml` configures Ruff rules `E`, `F`, `B` and `I`; `I001` (import sorting), `F401` (unused imports), and `F541` (f-strings without placeholders) are fixable. The `lint.ignore` list defers pre-existing violations — treat it as a debt ledger: fix the code and drop entries rather than adding new ones. - Before committing, run `uv run ruff check --fix` on the files you touched and fix every issue it reports. Keep fixes mechanical (rename, remove unused imports, sort imports); do not alter program logic while cleaning up — if a lint fix would change behavior, surface it instead of applying it silently. -- `.pre-commit-config.yaml` is the source of truth for enabled pre-commit hooks. Run them on the files you touched with `uv run pre-commit run --files ` and review the automatic edits so unrelated changes are not included. The pre-commit GitHub Actions workflow is manual-only (`workflow_dispatch`); the hooks are not enforced in CI. +- `.pre-commit-config.yaml` is the source of truth for enabled pre-commit hooks. Run them on the files you touched with `uv run pre-commit run --files ` and review the automatic edits so unrelated changes are not included. The pre-commit GitHub Actions workflow runs on pull requests, so the hooks are enforced in CI. - No general-purpose Python formatter is currently enforced (`ruff format` is deliberately not adopted yet). Preserve the surrounding file's formatting and avoid unrelated rewrites or repository-wide formatting. - Prefer double quotes for Python strings where consistent with the surrounding file. - Match existing docstring and comment style—concise English comments using imperative phrasing only where necessary. From f7656a992393ea3149d26eb5c4dde1a70567989a Mon Sep 17 00:00:00 2001 From: Shaisolaris <66793913+Shaisolaris@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:19:17 -0700 Subject: [PATCH 006/238] docs: drop stale README release entries (#3208) --- README.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/README.md b/README.md index 93b81f73df..0373ef7f60 100644 --- a/README.md +++ b/README.md @@ -106,25 +106,6 @@ pr-agent --pr_url https://github.com/owner/repo/pull/123 review Full notes for every release are on the [Releases page](https://github.com/the-pr-agent/pr-agent/releases). -### Jul 26, 2026 — [v0.41.0](https://github.com/the-pr-agent/pr-agent/releases/tag/v0.41.0) - -Claude Opus 5 support, and `docker/mosaico` became a self-contained deployment bundle. - -### Jul 25, 2026 — [v0.40.0](https://github.com/the-pr-agent/pr-agent/releases/tag/v0.40.0) - -Default model moved to GPT-5.6, Gemini 3.6 support, persistent inline comments (no more duplicate -suggestions across runs), an OpenRouter provider-routing/reasoning config, a tokenless -[plain-diff provider](https://docs.pr-agent.ai/usage-guide/plain_diff_mode/), and CI artifact -context injection. - -### Jul 5, 2026 — [v0.39.0](https://github.com/the-pr-agent/pr-agent/releases/tag/v0.39.0) - -`AGENTS.md` and friends are now fed to `/review`, `/describe` and `/improve` **by default**, so the -model picks up your project's conventions out of the box. Also: -[Agent Skills (`SKILL.md`)](https://docs.pr-agent.ai/core-abilities/agent_skills/), -[organization-level settings](https://docs.pr-agent.ai/usage-guide/configuration_options/), -[restricted mode](https://docs.pr-agent.ai/usage-guide/additional_configurations/#restricted-mode) -for reduced GitHub permissions, GitHub Checks as an output target, and Claude Sonnet 5 support. ## Why Use PR-Agent? From 1dcd3466002d38663220ee05ea3929fe59a27833 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:56:04 +0800 Subject: [PATCH 007/238] chore: pin uv 0.12.10 in pyproject.toml (#3153) --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e0bef92ffa..d0177c559f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,6 +135,11 @@ dev = [ "ruff==0.16.5", ] +[tool.uv] +# Keep this exact pin in sync with docker/Dockerfile and docker/Dockerfile.lambda +# so local, CI, release, and container builds use the same uv version. +required-version = "==0.12.10" + [tool.ruff] line-length = 120 From 29709d42e805fceab0a74580a191499b2d3c725a Mon Sep 17 00:00:00 2001 From: Aurora Date: Tue, 8 Sep 2026 17:56:36 +0200 Subject: [PATCH 008/238] test(providers): enforce capability predicate truth (#3156) Co-authored-by: Aurora --- .../test_git_provider_method_contract.py | 82 +++++++++++++++++++ .../unittest/test_review_comment_identity.py | 22 +++++ 2 files changed, 104 insertions(+) diff --git a/tests/unittest/test_git_provider_method_contract.py b/tests/unittest/test_git_provider_method_contract.py index ab7ae4d65d..c36f7d6eed 100644 --- a/tests/unittest/test_git_provider_method_contract.py +++ b/tests/unittest/test_git_provider_method_contract.py @@ -53,6 +53,18 @@ class MethodContract: check_execution: bool = True +@dataclass(frozen=True) +class DeliberateMismatch: + reason: str + + +@dataclass(frozen=True) +class PredicateContract: + name: str + evidence: tuple[str, ...] + deliberate_mismatches: dict[str, DeliberateMismatch] + + def _github(monkeypatch) -> GithubProvider: provider = GithubProvider.__new__(GithubProvider) provider.base_url = "https://api.github.example" @@ -183,6 +195,30 @@ def _is_success(value): REACTION_TIERS = _tiers(supported=("github", "gitlab", "gitea"), not_implemented=("gerrit",)) +PREDICATE_CONTRACTS = ( + PredicateContract( + name="supports_review_comment_identity", + evidence=("publish_persistent_comment",), + deliberate_mismatches={ + "gitea": DeliberateMismatch( + "Gitea forwards identity arguments but cannot safely activate identity tracking " + "until it normalizes dictionary-shaped comment payloads." + ), + }, + ), + PredicateContract( + name="supports_thread_resolution", + evidence=("resolve_comment_thread",), + deliberate_mismatches={ + "gitlab": DeliberateMismatch( + "GitLab resolves note IDs while /ask_line addresses discussion IDs, so thread resolution " + "must remain disabled." + ), + }, + ), +) + + METHOD_CONTRACTS = ( MethodContract( name="get_commit_messages", @@ -241,12 +277,58 @@ def _rows(tier: Tier | None = None, check_execution_only: bool = False): yield pytest.param(provider_name, contract, id=f"{provider_name}-{contract.name}") +def _predicate_rows(): + for contract in PREDICATE_CONTRACTS: + for provider_name in PROVIDERS: + yield pytest.param(provider_name, contract, id=f"{provider_name}-{contract.name}") + + +def _has_evidence(provider_type: type[GitProvider], contract: PredicateContract) -> bool: + for name in contract.evidence: + member = getattr(provider_type, name, None) + if member is not getattr(GitProvider, name, None): + if contract.name == "supports_review_comment_identity": + if "identity_marker" in inspect.signature(member).parameters: + return True + else: + return True + return False + + def test_every_registered_provider_has_a_contract_row(): contracted = {provider_type for provider_type, _ in PROVIDERS.values()} assert set(_GIT_PROVIDERS.values()) <= contracted +@pytest.mark.parametrize("provider_name,contract", tuple(_predicate_rows())) +def test_predicate_truth_matches_evidence(provider_name: str, contract: PredicateContract): + provider_type, _ = PROVIDERS[provider_name] + mismatch = contract.deliberate_mismatches.get(provider_name) + predicate = getattr(provider_type.__new__(provider_type), contract.name) + + if mismatch: + assert mismatch.reason.strip() + assert predicate() is False + else: + assert predicate() is _has_evidence(provider_type, contract) + + +@pytest.mark.parametrize("contract", PREDICATE_CONTRACTS, ids=lambda contract: contract.name) +def test_predicate_mismatches_name_only_providers_with_evidence(contract: PredicateContract): + for provider_name in contract.deliberate_mismatches: + provider_type, _ = PROVIDERS[provider_name] + + assert _has_evidence(provider_type, contract) + + +@pytest.mark.parametrize("contract", PREDICATE_CONTRACTS, ids=lambda contract: contract.name) +def test_predicate_contract_evidence_members_exist_on_registered_providers(contract: PredicateContract): + assert contract.evidence + for member_name in contract.evidence: + assert any(hasattr(provider_type, member_name) for provider_type, _ in PROVIDERS.values()) + + @pytest.mark.parametrize("contract", METHOD_CONTRACTS, ids=lambda contract: contract.name) def test_every_contract_row_places_every_provider_in_a_tier(contract: MethodContract): assert set(contract.tiers) == set(PROVIDERS) diff --git a/tests/unittest/test_review_comment_identity.py b/tests/unittest/test_review_comment_identity.py index edb9561280..e190d83925 100644 --- a/tests/unittest/test_review_comment_identity.py +++ b/tests/unittest/test_review_comment_identity.py @@ -13,6 +13,7 @@ ) from pr_agent.config_loader import get_settings from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider from pr_agent.git_providers.gitea_provider import GiteaProvider from pr_agent.git_providers.github_provider import GithubProvider from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -198,6 +199,27 @@ def test_azure_comment_path_forwards_review_identity(): } +def test_bitbucket_comment_path_forwards_review_identity(): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.pr = MagicMock() + provider.pr.comments.return_value = [] + provider.publish_comment = MagicMock() + review = "## Team Review 🔍\n\nbody" + legacy_header = "## PR Reviewer Guide 🔍" + + provider.publish_persistent_comment( + review, + initial_header="## Team Review 🔍", + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=legacy_header, + ) + + assert provider.supports_review_comment_identity() is True + provider.publish_comment.assert_called_once_with( + "## Team Review 🔍\n\n\n\nbody" + ) + + def test_gitea_keeps_identity_inactive_but_preserves_wrapper_arguments(): provider = GiteaProvider.__new__(GiteaProvider) published = object() From 6f2e0ff4bedd9f86d92b86e8899c1d2af262107d Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:03:38 +0800 Subject: [PATCH 009/238] fix(polling): bound active workers across polling iterations (#3063) --- docs/docs/installation/index.md | 20 +- pr_agent/servers/github_polling.py | 77 +++-- tests/unittest/test_github_polling.py | 444 +++++++++++++++++++++++++- 3 files changed, 510 insertions(+), 31 deletions(-) diff --git a/docs/docs/installation/index.md b/docs/docs/installation/index.md index e008d8a7aa..a8fa601ab4 100644 --- a/docs/docs/installation/index.md +++ b/docs/docs/installation/index.md @@ -23,7 +23,7 @@ behavior, not Requests-specific environment settings. ## Sizing a self-hosted webhook server -The GitHub, GitLab and Gitea webhook servers (the `github_app`, `gitlab_webhook` and `gitea_app` Docker targets) run under gunicorn with multiple worker processes, so that a worker busy handling a request cannot block the health check served by another. The other deployments — Bitbucket, Azure DevOps, GitHub polling, and the Lambda variants — run a single process and are unaffected by this section. +The GitHub, GitLab and Gitea webhook servers (the `github_app`, `gitlab_webhook` and `gitea_app` Docker targets) run under gunicorn with multiple worker processes, so that a worker busy handling a request cannot block the health check served by another. The other deployments — Bitbucket, Azure DevOps, GitHub polling, and the Lambda variants — do not use this gunicorn worker configuration. | Variable | Default | Description | |----------|---------|-------------| @@ -40,6 +40,24 @@ If the container is OOMKilled during startup, lower the worker count: GUNICORN_WORKERS=2 ``` +## GitHub polling workers + +GitHub polling uses one polling process and a separate child process for each +accepted comment. At most 10 of these child workers may be active across all +polling iterations. If an earlier batch still fills the limit, accepted work +waits for a free slot without blocking the event loop. This can delay the next +notification poll under sustained load. + +The existing per-batch limit is unchanged: only the first 10 queued comments in +a batch are accepted; excess work is logged and dropped. Waiting work is held in +memory only. Notification acknowledgement is unchanged, so shutdown or startup +failures do not guarantee redelivery. Dispatch interruptions log the remaining +count. A worker startup failure stops polling rather than risking further +dispatch with an untracked child; inspect the failure before restarting. +Completed children are joined without waiting and closed each iteration; +live children are not terminated on cancellation. This is not a durable queue, +a model-call deadline, or a host-wide memory limit. + !!! note "Docker Hub namespace migration" Releases **`0.34.2` and later** are published under [`pragent/pr-agent`](https://hub.docker.com/r/pragent/pr-agent). Older releases (up to and including `v0.31`) remain at the legacy [`codiumai/pr-agent`](https://hub.docker.com/r/codiumai/pr-agent) namespace as a frozen archive — no new images are pushed there. The examples on this site reference the new namespace; if you are pinning to a release before `0.34.2`, swap `pragent/pr-agent` for `codiumai/pr-agent` in your `image:` / `docker pull` / `uses: docker://` references. diff --git a/pr_agent/servers/github_polling.py b/pr_agent/servers/github_polling.py index 92ad1c94bf..d65b3d5c65 100644 --- a/pr_agent/servers/github_polling.py +++ b/pr_agent/servers/github_polling.py @@ -21,6 +21,11 @@ NOTIFICATION_URL = "https://api.github.com/notifications" DEFAULT_POLLING_REQUEST_TIMEOUT = 10 MAX_POLLING_REQUEST_TIMEOUT = 60 +POLLING_CAPACITY_CHECK_INTERVAL = 0.25 + + +class _PollingWorkerStartError(RuntimeError): + """Stop dispatch when child startup leaves process state uncertain.""" def _get_polling_request_timeout() -> float: @@ -196,23 +201,50 @@ async def is_valid_notification(notification, headers, handled_ids, session, use return False, handled_ids -def _start_queued_processes(task_queue, max_allowed_parallel_tasks): - """Start at most max_allowed_parallel_tasks queued jobs and clear the queue. +def _reap_finished_processes(active_processes): + """Release completed workers without waiting for live ones.""" + for process in active_processes[:]: + if not process.is_alive(): + process.join(timeout=0) + process.close() + active_processes.remove(process) - Do not join the started processes; let the polling loop move on to the next - iteration without waiting for them to complete. - """ - processes = [] - for i, (func, args) in enumerate(task_queue): - if i >= max_allowed_parallel_tasks: - get_logger().error( - f"Dropping {len(task_queue) - max_allowed_parallel_tasks} tasks from polling session") - break - process = multiprocessing.Process(target=func, args=args) - processes.append(process) - process.start() - task_queue.clear() - return processes + +async def _start_queued_processes(task_queue, max_allowed_parallel_tasks, active_processes): + """Keep the batch limit, waiting for capacity shared across polling iterations.""" + if max_allowed_parallel_tasks <= 0: + raise ValueError("The polling process limit must be positive") + overflow = len(task_queue) - max_allowed_parallel_tasks + if overflow > 0: + get_logger().error(f"Dropping {overflow} tasks from polling session") + for _ in range(overflow): + task_queue.pop() + + try: + while task_queue: + _reap_finished_processes(active_processes) + if len(active_processes) >= max_allowed_parallel_tasks: + await asyncio.sleep(POLLING_CAPACITY_CHECK_INTERVAL) + continue + func, args = task_queue[0] + process = multiprocessing.Process(target=func, args=args) + try: + process.start() + except BaseException as exc: + # Treat a PID-bearing child as potentially dispatched; never retry its task. + if process.pid is not None: + active_processes.append(process) + task_queue.popleft() + else: + process.close() + if isinstance(exc, Exception): + raise _PollingWorkerStartError("Polling worker startup failed; stopping dispatch") from exc + raise + active_processes.append(process) + task_queue.popleft() + finally: + if task_queue: + get_logger().error(f"Polling dispatch stopped with {len(task_queue)} tasks not dispatched") async def polling_loop(): @@ -239,8 +271,11 @@ async def polling_loop(): if not token: raise ValueError("User token must be set to get notifications") + active_processes = [] async with aiohttp.ClientSession() as session: while True: + task_queue = deque() + dispatch_started = False try: await asyncio.sleep(5) headers = { @@ -264,7 +299,6 @@ async def polling_loop(): if not notifications: continue get_logger().info(f"Received {len(notifications)} notifications") - task_queue = deque() for notification in notifications: if not notification: continue @@ -288,14 +322,21 @@ async def polling_loop(): max_allowed_parallel_tasks = 10 if task_queue: - _start_queued_processes(task_queue, max_allowed_parallel_tasks) + dispatch_started = True + await _start_queued_processes(task_queue, max_allowed_parallel_tasks, active_processes) elif response.status != 304: print(f"Failed to fetch notifications. Status code: {response.status}") + except _PollingWorkerStartError: + raise except Exception as e: get_logger().error(f"Polling exception during processing of a notification: {e}", artifact={"traceback": traceback.format_exc()}) + finally: + if task_queue and not dispatch_started: + get_logger().error(f"Polling dispatch stopped with {len(task_queue)} tasks not dispatched") + _reap_finished_processes(active_processes) if __name__ == '__main__': diff --git a/tests/unittest/test_github_polling.py b/tests/unittest/test_github_polling.py index ffa135ed9c..b6b9c73881 100644 --- a/tests/unittest/test_github_polling.py +++ b/tests/unittest/test_github_polling.py @@ -1,27 +1,447 @@ +import asyncio +import multiprocessing +import random +import time from collections import deque +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest from pr_agent.servers import github_polling -def test_start_queued_processes_respects_parallel_limit(monkeypatch): - created_processes = [] +@pytest.fixture +def workers(monkeypatch): + created = [] class FakeProcess: def __init__(self, target, args): self.target = target self.args = args - self.started = False - created_processes.append(self) + self.pid = None + self.alive = False + self.closed = False + self.joined = False + created.append(self) def start(self): - self.started = True + self.pid = len(created) + self.alive = True + assert sum(p.alive for p in created) <= 10 + + def is_alive(self): + assert not self.closed + return self.alive + + def join(self, timeout): + assert timeout == 0 + assert not self.alive + self.joined = True + + def close(self): + assert not self.alive + self.closed = True + + # Replace the module reference, not multiprocessing.Process process-wide. + monkeypatch.setattr(github_polling, "multiprocessing", SimpleNamespace(Process=FakeProcess)) + monkeypatch.setattr(github_polling, "get_logger", MagicMock()) + return created, FakeProcess + + +def _queue(size): + return deque((time.sleep, (i,)) for i in range(size)) + + +@pytest.mark.asyncio +async def test_start_queued_processes_respects_parallel_limit(workers): + created, _ = workers + active = [] + queue = _queue(12) + await github_polling._start_queued_processes(queue, 10, active) + assert len(created) == len(active) == 10 + assert all(process.alive for process in active) + assert [process.args for process in active] == [(i,) for i in range(10)] + assert not queue + + +@pytest.mark.asyncio +async def test_next_batch_waits_without_dropping_accepted_work(workers, monkeypatch): + created, _ = workers + active = [] + await github_polling._start_queued_processes(_queue(10), 10, active) + waiting = asyncio.Event() + release = asyncio.Event() + + async def wait_for_capacity(delay): + assert delay == 0.25 + waiting.set() + await release.wait() + + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=wait_for_capacity)) + queue = _queue(3) + task = asyncio.create_task(github_polling._start_queued_processes(queue, 10, active)) + try: + await asyncio.wait_for(waiting.wait(), 2) + assert len(created) == 10 + assert len(queue) == 3 + for process in created[:3]: + process.alive = False + release.set() + await asyncio.wait_for(task, 2) + assert len(created) == 13 + assert len(active) == 10 + assert all(p.joined and p.closed for p in created[:3]) + assert not queue + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_cancelled_capacity_wait_does_not_start_or_discard_work(workers, monkeypatch): + created, _ = workers + active = [] + await github_polling._start_queued_processes(_queue(10), 10, active) + entered = asyncio.Event() + + async def wait_for_capacity(delay): + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=wait_for_capacity)) + queue = _queue(2) + task = asyncio.create_task(github_polling._start_queued_processes(queue, 10, active)) + try: + await asyncio.wait_for(entered.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + _ = await task + assert len(created) == len(active) == 10 + assert len(queue) == 2 + github_polling.get_logger().error.assert_called_once_with( + "Polling dispatch stopped with 2 tasks not dispatched" + ) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("partial_start", [False, True]) +async def test_start_failure_keeps_previous_workers_tracked(workers, monkeypatch, partial_start): + created, process_type = workers + start = process_type.start + + def fail_second(process): + if len(created) == 2: + if partial_start: + start(process) + raise OSError("Cannot start worker") + start(process) + + monkeypatch.setattr(process_type, "start", fail_second) + active = [] + queue = _queue(3) + with pytest.raises(github_polling._PollingWorkerStartError, match="startup failed") as failure: + await github_polling._start_queued_processes(queue, 10, active) + assert isinstance(failure.value.__cause__, OSError) + assert active == (created if partial_start else created[:1]) + assert created[0].alive + assert created[1].closed is (not partial_start) + expected_remaining = 1 if partial_start else 2 + assert len(queue) == expected_remaining + github_polling.get_logger().error.assert_called_once_with( + f"Polling dispatch stopped with {expected_remaining} tasks not dispatched" + ) + assert len(created) == 2 + + +@pytest.mark.asyncio +async def test_many_batches_keep_the_active_limit(workers, monkeypatch): + created, _ = workers + active = [] + rng = random.Random(42) + accepted = 0 + + async def complete_workers(delay): + for process in active[:3]: + process.alive = False + + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=complete_workers)) + for _ in range(100): + size = rng.randint(1, 12) + accepted += min(size, 10) + await github_polling._start_queued_processes(_queue(size), 10, active) + assert len(active) <= 10 + for process in active[:rng.randint(0, len(active))]: + process.alive = False + assert len(created) == accepted + for process in active: + process.alive = False + github_polling._reap_finished_processes(active) + assert not active + assert all(p.joined and p.closed for p in created) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("idle_response", [304, 200, 500, OSError("poll failed")]) +async def test_polling_loop_reaps_workers_on_idle_and_failed_polls(workers, monkeypatch, idle_response): + created, _ = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + monkeypatch.setattr(github_polling, "is_valid_notification", AsyncMock(return_value=( + True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + ))) + finished = asyncio.Event() + polls = 0 + sleeps = 0 + + class Response: + def __init__(self, status): + self.status = status + self.headers = {} + + async def __aenter__(self): + if isinstance(self.status, Exception): + raise self.status + return self + + async def __aexit__(self, *args): + pass + + async def json(self): + return [{"id": 1}] if polls == 1 else [] + + class Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + nonlocal polls + polls += 1 + return Response(200 if polls == 1 else idle_response) + + async def sleep(delay): + nonlocal sleeps + sleeps += 1 + if sleeps == 2: + created[0].alive = False + if sleeps == 3: + finished.set() + await asyncio.Event().wait() + + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + task = asyncio.create_task(github_polling.polling_loop()) + try: + await asyncio.wait_for(finished.wait(), 2) + assert len(created) == 1 + assert created[0].joined and created[0].closed + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_reap_real_spawned_worker(monkeypatch): + ctx = multiprocessing.get_context("spawn") + monkeypatch.setattr(github_polling, "multiprocessing", SimpleNamespace(Process=ctx.Process)) + active = [] + await github_polling._start_queued_processes(deque([(time.sleep, (0,))]), 1, active) + process = active[0] + try: + await asyncio.to_thread(process.join, 5) + assert not process.is_alive() + github_polling._reap_finished_processes(active) + assert not active + with pytest.raises(ValueError, match="closed"): + process.is_alive() + finally: + if active: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + process.close() + + +@pytest.mark.asyncio +async def test_polling_loop_shares_capacity_between_batches(workers, monkeypatch): + created, _ = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + monkeypatch.setattr(github_polling, "is_valid_notification", AsyncMock(return_value=( + True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + ))) + waiting = asyncio.Event() + release = asyncio.Event() + finished = asyncio.Event() + polls = 0 + + class Session: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + nonlocal polls + polls += 1 + return self + + async def json(self): + return [{"id": i} for i in range(10 if polls == 1 else 2)] + + async def sleep(delay): + if delay == 0.25: + waiting.set() + await release.wait() + elif polls == 2: + finished.set() + await asyncio.Event().wait() + + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + task = asyncio.create_task(github_polling.polling_loop()) + try: + await asyncio.wait_for(waiting.wait(), 2) + assert len(created) == 10 + assert polls == 2 + for process in created[:2]: + process.alive = False + release.set() + await asyncio.wait_for(finished.wait(), 2) + assert len(created) == 12 + assert sum(process.alive for process in created) == 10 + finally: + task.cancel() + result, = await asyncio.gather(task, return_exceptions=True) + assert isinstance(result, asyncio.CancelledError) + + +@pytest.mark.asyncio +async def test_polling_loop_logs_accepted_work_cancelled_before_dispatch(workers, monkeypatch): + created, _ = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + entered_second = asyncio.Event() + validation_calls = 0 + + async def validate(*args, **kwargs): + nonlocal validation_calls + validation_calls += 1 + if validation_calls == 1: + return True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + entered_second.set() + await asyncio.Event().wait() + raise AssertionError("Second validation should remain blocked") + + monkeypatch.setattr(github_polling, "is_valid_notification", validate) + + class Session: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + return self + + async def json(self): + return [{"id": 1}, {"id": 2}] + + async def sleep(delay): + await asyncio.sleep(0) + + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + task = asyncio.create_task(github_polling.polling_loop()) + await asyncio.wait_for(entered_second.wait(), 2) + task.cancel() + result, = await asyncio.gather(task, return_exceptions=True) + + assert isinstance(result, asyncio.CancelledError) + assert not created + github_polling.get_logger().error.assert_any_call( + "Polling dispatch stopped with 1 tasks not dispatched" + ) + + +@pytest.mark.asyncio +async def test_polling_loop_stops_after_worker_start_failure(workers, monkeypatch): + _, process_type = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + monkeypatch.setattr(github_polling, "is_valid_notification", AsyncMock(return_value=( + True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + ))) + start = MagicMock(side_effect=OSError("Cannot start worker")) + monkeypatch.setattr(process_type, "start", start) + + class Session: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + return self - monkeypatch.setattr(github_polling.multiprocessing, "Process", FakeProcess) + async def json(self): + return [{"id": 1}] - task_queue = deque((lambda: None, ()) for _ in range(12)) - processes = github_polling._start_queued_processes(task_queue, 10) + async def sleep(delay): + await asyncio.sleep(0) - assert len(created_processes) == 10 - assert len(processes) == 10 - assert all(process.started for process in processes) - assert not task_queue + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + with pytest.raises(github_polling._PollingWorkerStartError, match="startup failed"): + await asyncio.wait_for(github_polling.polling_loop(), timeout=2) + start.assert_called_once() From d9395ee5a627186431e0aa3e1ec354ac51a851ef Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Wed, 9 Sep 2026 03:03:49 -0500 Subject: [PATCH 010/238] fix(describe): retain successful chunks after partial failure (#3163) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/tools/pr_description.py | 101 ++++++- .../test_linked_ticket_token_budget.py | 3 +- tests/unittest/test_pr_description.py | 257 +++++++++++++++++- .../unittest/test_pr_description_lifecycle.py | 34 +++ 4 files changed, 383 insertions(+), 12 deletions(-) diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index b1dc807a9e..ecb18c617e 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -43,6 +43,8 @@ fit_related_tickets_to_prompt_budget, ) +MAX_DESCRIPTION_COVERAGE_FILES = 50 + class PRDescription: def __init__(self, pr_url: str, args: list = None, @@ -149,6 +151,7 @@ async def run(self): if not self.git_provider.is_supported( "publish_file_comments") or not get_settings().pr_description.inline_file_summary: pr_body += "\n\n" + changes_walkthrough + "___\n\n" + pr_body += self._get_description_coverage_footer() get_logger().debug("PR output", artifact={"title": pr_title, "body": pr_body}) # Add help text if gfm_markdown is supported @@ -259,6 +262,9 @@ async def run(self): return "" async def _prepare_prediction(self, model: str) -> None: + self.description_total_chunk_count = 0 + self.description_failed_chunk_count = 0 + self.description_failed_files = [] if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description: get_logger().info("Markers were enabled, but user description does not contain markers. Skipping AI prediction") return None @@ -309,33 +315,87 @@ async def _prepare_prediction(self, model: str) -> None: self.git_provider, token_handler_only_files_prompt, model) # get the files prediction for each patch + chunk_pairs = list(zip(patches_compressed_list, files_in_patches_list, strict=True)) + self.description_total_chunk_count = len(chunk_pairs) + results = [None] * len(chunk_pairs) if not get_settings().pr_description.get("async_ai_calls", True): - results = [] - for i, patches in enumerate(patches_compressed_list): # sync calls + for i, (patches, _files_in_patch) in enumerate(chunk_pairs): # sync calls + if not patches: + continue patches_diff = "\n".join(patches) get_logger().debug(f"PR diff number {i + 1} for describe files") - prediction_files = await self._get_prediction(model, patches_diff, - prompt="pr_description_only_files_prompts") - results.append(prediction_files) + try: + results[i] = await self._get_prediction( + model, patches_diff, prompt="pr_description_only_files_prompts") + except Exception as e: + results[i] = e else: # async calls tasks = [] - for i, patches in enumerate(patches_compressed_list): + task_indices = [] + for i, (patches, _files_in_patch) in enumerate(chunk_pairs): if patches: patches_diff = "\n".join(patches) get_logger().debug(f"PR diff number {i + 1} for describe files") task = asyncio.create_task( self._get_prediction(model, patches_diff, prompt="pr_description_only_files_prompts")) tasks.append(task) + task_indices.append(i) # Wait for all tasks to complete - results = await asyncio.gather(*tasks) + task_results = await asyncio.gather(*tasks, return_exceptions=True) + for chunk_index, result in zip(task_indices, task_results, strict=True): + results[chunk_index] = result file_description_str_list = [] - for i, result in enumerate(results): + chunk_errors = [] + failed_files = [] + for i, (result, (_patches, files_in_patch)) in enumerate(zip(results, chunk_pairs, strict=True)): + if isinstance(result, Exception): + chunk_errors.append(result) + failed_files.extend(files_in_patch) + get_logger().warning( + f"Failed to generate description for chunk {i + 1}; retaining successful chunks", + artifact={"error": result, "files": files_in_patch}, + ) + continue + if isinstance(result, BaseException): + raise result + if not isinstance(result, str): + chunk_errors.append(ValueError(f"Description chunk {i + 1} returned no prediction")) + failed_files.extend(files_in_patch) + get_logger().warning( + f"Description chunk {i + 1} returned no prediction; retaining successful chunks", + artifact={"files": files_in_patch}, + ) + continue prediction_files = result.strip().removeprefix('```yaml').strip('`').strip() - if load_yaml(prediction_files, keys_fix_yaml=self.keys_fix) and prediction_files.startswith('pr_files'): + prediction_files_data = load_yaml(prediction_files, keys_fix_yaml=self.keys_fix) + file_descriptions = (prediction_files_data.get('pr_files') + if isinstance(prediction_files_data, dict) else None) + required_fields = ['filename', 'changes_title', 'label'] + if self.vars.get('include_file_summary_changes', True): + required_fields.append('changes_summary') + valid_file_descriptions = ( + isinstance(file_descriptions, list) and file_descriptions and + any(isinstance(file_description, dict) and + all(isinstance(file_description.get(field), str) and file_description[field].strip() + for field in required_fields) + for file_description in file_descriptions) + ) + if (prediction_files.startswith('pr_files') and isinstance(prediction_files_data, dict) and + valid_file_descriptions): prediction_files = prediction_files.removeprefix('pr_files:').strip() file_description_str_list.append(prediction_files) else: - get_logger().debug(f"failed to generate predictions in iteration {i + 1} for describe files") + chunk_errors.append(ValueError(f"Description chunk {i + 1} returned invalid YAML")) + failed_files.extend(files_in_patch) + get_logger().warning( + f"Failed to parse description chunk {i + 1}; retaining successful chunks", + artifact={"files": files_in_patch}, + ) + + self.description_failed_chunk_count = len(chunk_pairs) - len(file_description_str_list) + self.description_failed_files = list(dict.fromkeys(failed_files)) + if not file_description_str_list: + raise chunk_errors[0] if chunk_errors else ValueError("No description chunks were generated") # generate files_walkthrough string, with proper token handling self.vars, token_handler_only_description_prompt = fit_related_tickets_to_prompt_budget( @@ -390,6 +450,27 @@ async def _prepare_prediction(self, model: str) -> None: get_logger().debug(f"Using only headers for describe {self.pr_id}") self.prediction = prediction_headers + def _get_description_coverage_footer(self) -> str: + failed_chunk_count = getattr(self, "description_failed_chunk_count", 0) + if not failed_chunk_count: + return "" + + total_chunk_count = getattr(self, "description_total_chunk_count", failed_chunk_count) + footer = ( + f"\n\n⚠️ **Description coverage:** {failed_chunk_count} of {total_chunk_count} " + "file-description chunks failed; the description above is based on the successful chunks only." + ) + failed_files = getattr(self, "description_failed_files", []) + if failed_files: + displayed_files = failed_files[:MAX_DESCRIPTION_COVERAGE_FILES] + footer += "\n\nFiles from failed chunks may not be covered:\n" + "\n".join( + f"- `{filename}`" for filename in displayed_files + ) + remaining_count = len(failed_files) - len(displayed_files) + if remaining_count: + footer += f"\n... and {remaining_count} more" + return footer + async def extend_uncovered_files(self, original_prediction: str) -> str: try: prediction = original_prediction diff --git a/tests/unittest/test_linked_ticket_token_budget.py b/tests/unittest/test_linked_ticket_token_budget.py index d855c5f554..babf86da39 100644 --- a/tests/unittest/test_linked_ticket_token_budget.py +++ b/tests/unittest/test_linked_ticket_token_budget.py @@ -258,7 +258,8 @@ def get_multiple_patches(_provider, token_handler, _model): async def get_prediction(_model, patches_diff=None, prompt=None): prediction_calls.append((prompt, patches_diff, tool.vars)) if prompt == "pr_description_only_files_prompts": - return "pr_files:\n- filename: src/app.py" + return ("pr_files:\n- filename: src/app.py\n changes_title: Update app\n" + " changes_summary: Updates app behavior.\n label: enhancement") return "title: Test\ndescription: Test" monkeypatch.setattr(module, "fit_related_tickets_to_prompt_budget", fit_payload) diff --git a/tests/unittest/test_pr_description.py b/tests/unittest/test_pr_description.py index fb2d2ae27a..9ec865cee7 100644 --- a/tests/unittest/test_pr_description.py +++ b/tests/unittest/test_pr_description.py @@ -1,11 +1,13 @@ +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml from jinja2 import Environment, StrictUndefined +from pr_agent.algo.pr_processing import retry_with_fallback_models from pr_agent.algo.types import FilePatchInfo -from pr_agent.algo.utils import load_yaml +from pr_agent.algo.utils import ModelType, load_yaml from pr_agent.config_loader import get_settings from pr_agent.tools.pr_description import ( PRDescription, @@ -68,6 +70,29 @@ def _make_large_pr_instance(diff_files=None): return obj +def _large_pr_chunks(): + chunks = [ + ["diff --git a/src/file1.py b/src/file1.py\n... file1 ..."], + ["diff --git a/src/file2.py b/src/file2.py\n... file2 ..."], + ] + return chunks, [10, 10], [], [], {}, [["src/file1.py"], ["src/file2.py"]] + + +def _file_prediction(filename: str) -> str: + return f"""pr_files: +- filename: {filename} + changes_title: Describe {filename} + changes_summary: File summary + label: enhancement""" + + +def _header_prediction() -> str: + return """type: +- Enhancement +title: Partial description +description: Summarizes the successfully described files.""" + + def _mock_settings(pr_diagram_direction: str = 'adaptive', pr_diagram_direction_threshold: int = 5): """Mock get_settings used by _prepare_data.""" settings = MagicMock() @@ -583,6 +608,235 @@ async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt assert obj.data["title"].strip() == "Combined PR Title" assert [f["filename"].strip() for f in obj.data["pr_files"]] == ["src/file1.py", "src/file2.py"] + @pytest.mark.asyncio + @pytest.mark.parametrize("async_calls", [True, False]) + async def test_large_pr_retains_valid_chunk_when_sibling_raises(self, monkeypatch, async_calls): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", async_calls) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + if "file1" in patches_diff: + return _file_prediction("src/file1.py") + raise RuntimeError("file2 transport failed") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ): + await obj._prepare_prediction("gpt-4o") + + parsed = load_yaml(obj.prediction, keys_fix_yaml=obj.keys_fix) + assert [file["filename"].strip() for file in parsed["pr_files"]] == ["src/file1.py", "src/file2.py"] + assert parsed["pr_files"][1]["label"].strip() == "additional files" + assert obj.description_failed_chunk_count == 1 + assert obj.description_total_chunk_count == 2 + assert obj.description_failed_files == ["src/file2.py"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("malformed_prediction", [ + "pr_files: []", + "pr_files:\n- not-a-file-record", + "pr_files:\n- {}", + """pr_files: +- filename: src/file2.py + changes_title: Missing label + changes_summary: File summary + label:""", + ]) + async def test_large_pr_retains_valid_chunk_when_sibling_is_malformed( + self, monkeypatch, malformed_prediction): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + if "file1" in patches_diff: + return _file_prediction("src/file1.py") + return malformed_prediction + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ): + await obj._prepare_prediction("gpt-4o") + + parsed = load_yaml(obj.prediction, keys_fix_yaml=obj.keys_fix) + assert [file["filename"].strip() for file in parsed["pr_files"]] == ["src/file1.py", "src/file2.py"] + assert obj.description_failed_chunk_count == 1 + assert obj.description_failed_files == ["src/file2.py"] + + @pytest.mark.asyncio + async def test_large_pr_keeps_complete_records_from_mixed_chunk(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + return """pr_files: +- filename: src/file1.py + changes_title: Describe src/file1.py + changes_summary: File summary + label: enhancement +- filename: src/file2.py + changes_title: Missing summary + label: enhancement""" + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=( + [["diff --git a/src/file1.py b/src/file1.py\n... file1 ...", + "diff --git a/src/file2.py b/src/file2.py\n... file2 ..."]], + [20], + [], + [], + {}, + [["src/file1.py", "src/file2.py"]], + ), + ): + await obj._prepare_prediction("gpt-4o") + + parsed = load_yaml(obj.prediction, keys_fix_yaml=obj.keys_fix) + assert [file["filename"].strip() for file in parsed["pr_files"]] == ["src/file1.py", "src/file2.py"] + obj._prepare_data() + assert obj._prepare_file_labels() == { + "enhancement": [("src/file1.py", "Describe src/file1.py", "File summary")] + } + + @pytest.mark.asyncio + @pytest.mark.parametrize("async_calls", [True, False]) + async def test_large_pr_all_failed_chunks_raise_for_model_fallback(self, monkeypatch, async_calls): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", async_calls) + chunk_calls = [] + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + chunk_calls.append(patches_diff) + raise RuntimeError(f"{patches_diff.splitlines()[0]} failed") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), pytest.raises(RuntimeError, match="file1.py"): + await obj._prepare_prediction("gpt-4o") + + assert len(chunk_calls) == 2 + + @pytest.mark.asyncio + async def test_large_pr_all_malformed_chunks_raise_for_model_fallback(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + obj._get_prediction = AsyncMock(return_value="pr_files:\n- not-a-file-record") + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), pytest.raises(ValueError, match="invalid YAML"): + await obj._prepare_prediction("gpt-4o") + + assert all( + call.kwargs["prompt"] == "pr_description_only_files_prompts" + for call in obj._get_prediction.await_args_list + ) + + @pytest.mark.asyncio + async def test_large_pr_empty_chunk_keeps_result_associated_with_its_files(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + chunks = [[], ["diff --git a/src/file2.py b/src/file2.py\n... file2 ..."]] + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + return _file_prediction("src/file2.py") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=(chunks, [0, 10], [], [], {}, [["src/file1.py"], ["src/file2.py"]]), + ): + await obj._prepare_prediction("gpt-4o") + + assert obj.description_failed_files == ["src/file1.py"] + assert obj.description_failed_chunk_count == 1 + assert len(obj._get_prediction.await_args_list) == 2 # one non-empty chunk and the header pass + + @pytest.mark.asyncio + async def test_large_pr_chunk_cancellation_propagates(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if "file2" in patches_diff: + raise asyncio.CancelledError() + return _file_prediction("src/file1.py") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), pytest.raises(asyncio.CancelledError): + await obj._prepare_prediction("gpt-4o") + + @pytest.mark.asyncio + async def test_large_pr_all_fail_then_fallback_model_keeps_partial_result(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + calls = [] + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + calls.append((model, prompt, patches_diff)) + if model == "gpt-4o": + raise RuntimeError("primary chunk failed") + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + if "file1" in patches_diff: + return _file_prediction("src/file1.py") + raise RuntimeError("fallback file2 failed") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), patch( + "pr_agent.algo.pr_processing._get_all_models", return_value=["gpt-4o", "gpt-4o-mini"] + ), patch( + "pr_agent.algo.pr_processing._get_all_deployments", return_value=[None, None] + ), patch( + "pr_agent.algo.pr_processing.route_primary_model", return_value=None + ): + await retry_with_fallback_models(obj._prepare_prediction, ModelType.WEAK) + + assert [(model, prompt) for model, prompt, _ in calls] == [ + ("gpt-4o", "pr_description_only_files_prompts"), + ("gpt-4o", "pr_description_only_files_prompts"), + ("gpt-4o-mini", "pr_description_only_files_prompts"), + ("gpt-4o-mini", "pr_description_only_files_prompts"), + ("gpt-4o-mini", "pr_description_only_description_prompts"), + ] + assert obj.description_failed_chunk_count == 1 + assert obj.description_failed_files == ["src/file2.py"] + + def test_description_coverage_footer_names_failed_chunks_and_files(self): + obj = _make_large_pr_instance() + obj.description_failed_chunk_count = 1 + obj.description_total_chunk_count = 2 + obj.description_failed_files = ["src/file2.py"] + + footer = obj._get_description_coverage_footer() + + assert "1 of 2 file-description chunks failed" in footer + assert "based on the successful chunks only" in footer + assert "`src/file2.py`" in footer + assert "
" not in footer + @pytest.mark.asyncio async def test_prepare_prediction_normal_diff_uses_single_prompt(self): """Verify normal diff under token limit does NOT fall into the large-PR multi-patch path.""" @@ -632,6 +886,7 @@ async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt return """pr_files: - filename: src/file1.py changes_title: Add diagram support + changes_summary: Adds diagram support. label: enhancement""" elif prompt == "pr_description_only_description_prompts": return """type: diff --git a/tests/unittest/test_pr_description_lifecycle.py b/tests/unittest/test_pr_description_lifecycle.py index a00f0113ac..c7a5c89d45 100644 --- a/tests/unittest/test_pr_description_lifecycle.py +++ b/tests/unittest/test_pr_description_lifecycle.py @@ -148,6 +148,40 @@ async def test_run_neutralizes_progress_comment_before_delete(monkeypatch): restore_settings(settings_snapshot) +@pytest.mark.asyncio +async def test_run_appends_partial_description_coverage_to_output(monkeypatch): + settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) + try: + provider = MagicMock() + provider.is_supported.return_value = False + description = _make_description(provider) + description.prediction = "generated" + description.description_failed_chunk_count = 1 + description.description_total_chunk_count = 2 + description.description_failed_files = ["src/file2.py"] + description._prepare_data = MagicMock() + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "", [])) + + monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) + settings = get_settings() + settings.config.publish_output = False + settings.pr_description.enable_help_comment = False + settings.pr_description.enable_help_text = False + settings.pr_description.enable_semantic_files_types = False + settings.pr_description.publish_labels = False + settings.pr_description.use_description_markers = False + + await description.run() + + artifact = get_settings().data["artifact"] + assert "Description coverage" in artifact + assert "1 of 2 file-description chunks failed" in artifact + assert "`src/file2.py`" in artifact + finally: + restore_settings(settings_snapshot) + + @pytest.mark.asyncio @pytest.mark.parametrize("propagate_tool_errors", [False, True]) async def test_run_reports_description_publication_failure(monkeypatch, propagate_tool_errors): From bf05b46847921474202f1f5d0d286ce2868775e2 Mon Sep 17 00:00:00 2001 From: BingEdward <1042653432@qq.com> Date: Wed, 9 Sep 2026 16:03:56 +0800 Subject: [PATCH 011/238] fix(deps): declare certifi as a range per the security-pin policy (#3204) Co-authored-by: Claude Opus 5 --- pyproject.toml | 2 +- tests/unittest/test_certifi_pin_policy.py | 34 +++++++++++++++++++++++ uv.lock | 8 +++--- 3 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 tests/unittest/test_certifi_pin_policy.py diff --git a/pyproject.toml b/pyproject.toml index d0177c559f..d04271fbc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "azure-devops==7.1.0b4", "azure-identity==1.25.0", "boto3==1.43.78", - "certifi==2024.8.30", + "certifi>=2026.7.22", "dynaconf>=3.2.13,<4", "fastapi>=0.141.1,<1", "GitPython>=3.1.59,<4", diff --git a/tests/unittest/test_certifi_pin_policy.py b/tests/unittest/test_certifi_pin_policy.py new file mode 100644 index 0000000000..2e12c88e0c --- /dev/null +++ b/tests/unittest/test_certifi_pin_policy.py @@ -0,0 +1,34 @@ +"""Guard for the security-pin policy declared above ``dependencies`` in pyproject.toml. + +certifi is the CA trust store every outbound HTTPS call in pr-agent ultimately +validates against. An ``==`` pin freezes that bundle for pip consumers, who then +cannot pick up a rebuilt bundle -- one that drops a distrusted root, say -- without +overriding our requirement. The policy comment in pyproject.toml says +security-sensitive packages declare ranges for exactly this reason (#2523, #3194); +this test is what keeps the comment honest, since nothing else checks it. + +This asserts the declared requirement only. Exact versions for this repo's own +builds still come from uv.lock, so reproducibility is unaffected. +""" +import tomllib + +CERTIFI_POLICY = ( + "certifi must declare a floor (>=), not an == pin: it is the CA trust store, and an exact " + "pin leaves pip consumers stuck on a stale certificate bundle. Exact versions for this " + "repo's own builds come from uv.lock. See the comment above [project].dependencies." +) + + +def _certifi_requirement(pytestconfig): + with open(pytestconfig.inipath, "rb") as f: + dependencies = tomllib.load(f)["project"]["dependencies"] + + matches = [dep for dep in dependencies if dep.lower().startswith("certifi")] + assert len(matches) == 1, f"expected exactly one certifi requirement, found {matches!r}" + return matches[0] + + +def test_certifi_is_not_exact_pinned(pytestconfig): + requirement = _certifi_requirement(pytestconfig) + assert "==" not in requirement, f"{requirement!r}: {CERTIFI_POLICY}" + assert ">=" in requirement, f"{requirement!r}: {CERTIFI_POLICY}" diff --git a/uv.lock b/uv.lock index b789db90f8..974b8374db 100644 --- a/uv.lock +++ b/uv.lock @@ -330,11 +330,11 @@ wheels = [ [[package]] name = "certifi" -version = "2024.8.30" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] @@ -2610,7 +2610,7 @@ requires-dist = [ { name = "azure-devops", specifier = "==7.1.0b4" }, { name = "azure-identity", specifier = "==1.25.0" }, { name = "boto3", specifier = "==1.43.78" }, - { name = "certifi", specifier = "==2024.8.30" }, + { name = "certifi", specifier = ">=2026.7.22" }, { name = "dynaconf", specifier = ">=3.2.13,<4" }, { name = "fastapi", specifier = ">=0.141.1,<1" }, { name = "giteapy", specifier = "==1.0.8" }, From 172d261e2bd91f842eeaea0ad5100f65abc3a1ec Mon Sep 17 00:00:00 2001 From: BingEdward <1042653432@qq.com> Date: Wed, 9 Sep 2026 16:04:03 +0800 Subject: [PATCH 012/238] refactor(help): select help rendering by provider capability (#3213) Co-authored-by: Claude Opus 5 --- .../bitbucket_server_provider.py | 4 + pr_agent/git_providers/git_provider.py | 15 +++ pr_agent/git_providers/github_provider.py | 3 + pr_agent/tools/pr_help_message.py | 12 ++- tests/unittest/test_command_descriptions.py | 8 ++ .../test_pr_help_message_rendering.py | 94 +++++++++++++++++++ 6 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/unittest/test_pr_help_message_rendering.py diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index d2ac3b16d7..aebed5acb6 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -222,6 +222,10 @@ def is_supported(self, capability: str) -> bool: return False return True + def supports_markdown_tables(self) -> bool: + # Bitbucket Data Center renders Markdown tables in comments, but not GFM. + return True + def set_pr(self, pr_url: str): self.workspace_slug, self.repo_slug, self.pr_num = self._parse_pr_url(pr_url) self.pr = self._get_pr() diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 7c11bf8de5..a7fb9f0352 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -184,6 +184,21 @@ def supports_threaded_pr_questions(self) -> bool: def supports_line_question_history(self) -> bool: return False + def supports_checkbox_commands(self) -> bool: + """Whether a published comment renders command checkboxes as checkboxes. + + Providers that render `- [ ]` as a tickable box override this; the default is no + support, so tools render commands as text instead.""" + return False + + def supports_markdown_tables(self) -> bool: + """Whether comments render pipe-table markdown. + + Only consulted for providers without `gfm_markdown`, so that tools can degrade to + a plain table instead of refusing to render. Providers that render Markdown tables + but not GitHub-flavored markdown override this.""" + return False + #Given a url (issues or PR/MR) - get the .git repo url to which they belong. Needs to be implemented by the provider. def get_git_repo_url(self, issues_or_pr_url: str) -> str: get_logger().warning("Not implemented! Returning empty url") diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index eb9eb54d09..03993c8638 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -125,6 +125,9 @@ def is_supported(self, capability: str) -> bool: def supports_line_question_history(self) -> bool: return True + def supports_checkbox_commands(self) -> bool: + return True + def _get_owner_and_repo_path(self, given_url: str) -> str: try: repo_path = None diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index ae767aebd4..54dc980346 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -13,7 +13,7 @@ from pr_agent.algo.utils import ModelType, clip_tokens, get_max_tokens, load_yaml from pr_agent.command_descriptions import COMMAND_DESCRIPTIONS from pr_agent.config_loader import get_settings -from pr_agent.git_providers import BitbucketServerProvider, GithubProvider, get_git_provider_with_context +from pr_agent.git_providers import get_git_provider_with_context from pr_agent.log import get_logger @@ -190,7 +190,8 @@ async def run(self): else: get_logger().info(f"Answer:\n{answer_str}") else: - if not isinstance(self.git_provider, BitbucketServerProvider) and not self.git_provider.is_supported("gfm_markdown"): + supports_gfm_markdown = self.git_provider.is_supported("gfm_markdown") + if not supports_gfm_markdown and not self.git_provider.supports_markdown_tables(): self.git_provider.publish_comment( "The `Help` tool requires gfm markdown, which is not supported by your code platform.") return @@ -242,15 +243,16 @@ async def run(self): checkbox_list.append("[*]") checkbox_list.append("[*]") - if isinstance(self.git_provider, GithubProvider) and not get_settings().config.get('disable_checkboxes', False): + if (supports_gfm_markdown and self.git_provider.supports_checkbox_commands() + and not get_settings().config.get('disable_checkboxes', False)): pr_comment += "" for i in range(len(tool_names)): pr_comment += f"\n\n\n" pr_comment += "
ToolDescriptionTrigger Interactively :gem:
\n\n{tool_names[i]}{descriptions[i]}\n\n{checkbox_list[i]}\n
\n\n" pr_comment += """\n\n(1) Note that each tool can be [triggered automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) when a new PR is opened, or called manually by [commenting on a PR](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#online-usage).""" pr_comment += """\n\n(2) Tools marked with [*] require additional parameters to be passed. For example, to invoke the `/ask` tool, you need to comment on a PR: `/ask ""`. See the relevant documentation for each tool for more details.""" - elif isinstance(self.git_provider, BitbucketServerProvider): - # only support basic commands in BBDC + elif not supports_gfm_markdown: + # only basic commands, in a plain markdown table (e.g. BBDC) pr_comment = generate_bbdc_table(tool_names[:4], descriptions[:4]) else: pr_comment += "" diff --git a/tests/unittest/test_command_descriptions.py b/tests/unittest/test_command_descriptions.py index 4e67007fa3..8916f8f2d6 100644 --- a/tests/unittest/test_command_descriptions.py +++ b/tests/unittest/test_command_descriptions.py @@ -48,6 +48,14 @@ def __init__(self): def is_supported(_feature): return True + @staticmethod + def supports_checkbox_commands(): + return False + + @staticmethod + def supports_markdown_tables(): + return False + def publish_comment(self, comment): self.comment = comment diff --git a/tests/unittest/test_pr_help_message_rendering.py b/tests/unittest/test_pr_help_message_rendering.py new file mode 100644 index 0000000000..879a95550e --- /dev/null +++ b/tests/unittest/test_pr_help_message_rendering.py @@ -0,0 +1,94 @@ +"""The /help walkthrough picks its rendering from provider capabilities, not provider classes.""" + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider +from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.git_providers.gitlab_provider import GitLabProvider +from pr_agent.tools.pr_help_message import PRHelpMessage +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +INTERACTIVE_MARKER = "Trigger Interactively" +PLAIN_TABLE_MARKER = "| Tool | Description |" +UNSUPPORTED_MARKER = "requires gfm markdown" + + +class StubProvider: + """A provider that is none of the concrete classes the tool used to branch on.""" + + def __init__(self, gfm_markdown: bool, markdown_tables: bool = False, checkbox_commands: bool = False): + self._gfm_markdown = gfm_markdown + self._markdown_tables = markdown_tables + self._checkbox_commands = checkbox_commands + self.published = [] + + def is_supported(self, capability: str) -> bool: + return self._gfm_markdown if capability == "gfm_markdown" else True + + def supports_markdown_tables(self) -> bool: + return self._markdown_tables + + def supports_checkbox_commands(self) -> bool: + return self._checkbox_commands + + def publish_comment(self, pr_comment: str, is_temporary: bool = False): + self.published.append(pr_comment) + + +async def run_walkthrough(provider) -> str: + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = provider + tool.question_str = "" + tool.return_as_string = False + await tool.run() + assert len(provider.published) == 1 + return provider.published[0] + + +@pytest.fixture +def published_output(): + snapshot = snapshot_settings(["config.publish_output", "config.disable_checkboxes"]) + get_settings().set("config.publish_output", True) + yield + restore_settings(snapshot) + + +@pytest.mark.parametrize( + "capabilities, expected, unexpected", + [ + # gfm markdown plus checkbox commands is the only combination that offers the checkboxes + (dict(gfm_markdown=True, checkbox_commands=True), INTERACTIVE_MARKER, PLAIN_TABLE_MARKER), + (dict(gfm_markdown=True), "
ToolCommandDescription
", INTERACTIVE_MARKER), + # without gfm markdown, a provider that renders pipe tables gets the basic table + (dict(gfm_markdown=False, markdown_tables=True), PLAIN_TABLE_MARKER, "
"), + (dict(gfm_markdown=False), UNSUPPORTED_MARKER, "
"), + ], +) +async def test_rendering_follows_capabilities(published_output, capabilities, expected, unexpected): + comment = await run_walkthrough(StubProvider(**capabilities)) + assert expected in comment + assert unexpected not in comment + + +async def test_checkboxes_stay_disabled_by_configuration(published_output): + get_settings().set("config.disable_checkboxes", True) + comment = await run_walkthrough(StubProvider(gfm_markdown=True, checkbox_commands=True)) + assert INTERACTIVE_MARKER not in comment + assert "
" in comment + + +@pytest.mark.parametrize( + "provider_class, checkbox_commands, markdown_tables", + [ + (GithubProvider, True, False), + (GitLabProvider, False, False), + (BitbucketProvider, False, False), + (BitbucketServerProvider, False, True), + ], +) +def test_providers_declare_the_capabilities_the_tool_reads(provider_class, checkbox_commands, markdown_tables): + provider = provider_class.__new__(provider_class) + assert provider.supports_checkbox_commands() is checkbox_commands + assert provider.supports_markdown_tables() is markdown_tables From 00b8c9d4db829bbe98f5a8275e30b778e7fa8c10 Mon Sep 17 00:00:00 2001 From: Aurora Date: Wed, 9 Sep 2026 10:04:16 +0200 Subject: [PATCH 013/238] fix(providers): report code-suggestion publication failure (#3219) Co-authored-by: Aurora --- pr_agent/git_providers/codecommit_provider.py | 11 +- pr_agent/git_providers/gerrit_provider.py | 15 ++- .../test_git_provider_method_contract.py | 115 ++++++++++++++++++ 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index 6f6b8350a9..40112dd0d5 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -214,12 +214,15 @@ def publish_comment(self, pr_comment: str, is_temporary: bool = False): def publish_code_suggestions(self, code_suggestions: list) -> bool: counter = 1 + publishable_count = 0 + published_count = 0 for suggestion in code_suggestions: # Verify that each suggestion has the required keys if not all(key in suggestion for key in ["body", "relevant_file", "relevant_lines_start"]): get_logger().warning(f"Skipping code suggestion #{counter}: Each suggestion must have 'body', 'relevant_file', 'relevant_lines_start' keys") continue + publishable_count += 1 target_contexts = self._get_target_contexts_for_file(suggestion["relevant_file"]) for target in target_contexts: try: @@ -236,15 +239,15 @@ def publish_code_suggestions(self, code_suggestions: list) -> bool: annotation_file=suggestion["relevant_file"], annotation_line=suggestion["relevant_lines_start"], ) + published_count += 1 except Exception as e: raise ValueError(f"CodeCommit Cannot publish code suggestions for PR: {self.pr_num}") from e counter += 1 - # The calling function passes in a list of code suggestions, and this function publishes each suggestion one at a time. - # If we were to return False here, the calling function will attempt to publish the same list of code suggestions again, one at a time. - # Since this function publishes the suggestions one at a time anyway, we always return True here to avoid the retry. - return True + # A partial failure must not report failure: the caller republishes the whole + # list, which would post the already-accepted suggestions a second time. + return published_count > 0 or publishable_count == 0 def publish_labels(self, labels): return [""] # not implemented yet diff --git a/pr_agent/git_providers/gerrit_provider.py b/pr_agent/git_providers/gerrit_provider.py index aa5603b770..dd9d0e4b1b 100644 --- a/pr_agent/git_providers/gerrit_provider.py +++ b/pr_agent/git_providers/gerrit_provider.py @@ -387,8 +387,10 @@ def split_suggestion(self, msg) -> tuple[str, str]: '\n'.join(context) + '\n' if context else '' ) - def publish_code_suggestions(self, code_suggestions: list): + def publish_code_suggestions(self, code_suggestions: list) -> bool: msg = [] + publishable_count = 0 + published_count = 0 repo_root = pathlib.Path(self.repo_path).resolve() for suggestion in code_suggestions: # Validate suggestion structure before accessing keys @@ -402,6 +404,8 @@ def publish_code_suggestions(self, code_suggestions: list): except ValueError: get_logger().warning(f"Skipping suggestion with path traversal: {suggestion['relevant_file']}") continue + + publishable_count += 1 description, code = self.split_suggestion(suggestion['body']) add_suggestion( target_path, @@ -417,8 +421,13 @@ def publish_code_suggestions(self, code_suggestions: list): msg.append(f'* {description}\n{full_path}') if msg: - add_comment(self.parsed_url, self.refspec, "\n".join(msg)) - return True + try: + add_comment(self.parsed_url, self.refspec, "\n".join(msg)) + published_count += 1 + except Exception as e: + get_logger().exception("Failed to publish Gerrit code suggestions: {}", e) + + return published_count > 0 or publishable_count == 0 def publish_comment(self, pr_comment: str, is_temporary: bool = False): if not is_temporary: diff --git a/tests/unittest/test_git_provider_method_contract.py b/tests/unittest/test_git_provider_method_contract.py index c36f7d6eed..f808732155 100644 --- a/tests/unittest/test_git_provider_method_contract.py +++ b/tests/unittest/test_git_provider_method_contract.py @@ -395,3 +395,118 @@ def test_disable_eyes_short_circuits_before_any_backend_call(provider_name: str) provider = provider_type.__new__(provider_type) assert provider.add_eyes_reaction(COMMENT_ID, disable_eyes=True) is None + + +@pytest.mark.parametrize( + "provider_name", + [ + name + for name, (cls, _) in PROVIDERS.items() + if "publish_code_suggestions" in cls.__dict__ + ], +) +def test_publish_code_suggestions_declares_bool_return(provider_name: str): + provider_type, _ = PROVIDERS[provider_name] + hints = get_type_hints(provider_type.publish_code_suggestions) + assert hints.get("return") is bool + + +def test_gerrit_publish_code_suggestions_returns_false_on_total_failure(monkeypatch, tmp_path): + provider = GerritProvider.__new__(GerritProvider) + provider.parsed_url = SimpleNamespace() + provider.refspec = "refs/changes/1" + provider.repo_path = str(tmp_path) + (tmp_path / "app.py").write_text("orig\n") + + monkeypatch.setattr( + "pr_agent.git_providers.gerrit_provider.upload_patch", + lambda *_: "https://patch.example/1", + ) + monkeypatch.setattr( + "pr_agent.git_providers.gerrit_provider.diff", + lambda *_, **__: "patch", + ) + monkeypatch.setattr( + "pr_agent.git_providers.gerrit_provider.reset_local_changes", + lambda *_: None, + ) + + def _fail_comment(*_): + raise RuntimeError("network down") + + monkeypatch.setattr("pr_agent.git_providers.gerrit_provider.add_comment", _fail_comment) + + suggestions = [{ + "relevant_file": "app.py", + "body": "description\n```suggestion\nnew\n```", + "relevant_lines_start": 1, + "relevant_lines_end": 1, + }] + assert provider.publish_code_suggestions(suggestions) is False + + +def test_gerrit_publish_code_suggestions_returns_true_on_success(monkeypatch, tmp_path): + provider = GerritProvider.__new__(GerritProvider) + provider.parsed_url = SimpleNamespace() + provider.refspec = "refs/changes/1" + provider.repo_path = str(tmp_path) + (tmp_path / "app.py").write_text("orig\n") + + monkeypatch.setattr( + "pr_agent.git_providers.gerrit_provider.upload_patch", + lambda *_: "https://patch.example/1", + ) + monkeypatch.setattr( + "pr_agent.git_providers.gerrit_provider.diff", + lambda *_, **__: "patch", + ) + monkeypatch.setattr( + "pr_agent.git_providers.gerrit_provider.reset_local_changes", + lambda *_: None, + ) + monkeypatch.setattr( + "pr_agent.git_providers.gerrit_provider.add_comment", + lambda *_: None, + ) + + suggestions = [{ + "relevant_file": "app.py", + "body": "description\n```suggestion\nnew\n```", + "relevant_lines_start": 1, + "relevant_lines_end": 1, + }] + assert provider.publish_code_suggestions(suggestions) is True + + +def test_codecommit_publish_code_suggestions_returns_false_when_no_publishable_targets(): + provider = CodeCommitProvider.__new__(CodeCommitProvider) + provider.pr_num = 123 + provider.codecommit_client = MagicMock() + provider._get_target_contexts_for_file = MagicMock(return_value=[]) + + suggestions = [{ + "body": "suggestion", + "relevant_file": "app.py", + "relevant_lines_start": 1, + }] + assert provider.publish_code_suggestions(suggestions) is False + provider.codecommit_client.publish_comment.assert_not_called() + + +def test_codecommit_publish_code_suggestions_returns_true_on_success(): + provider = CodeCommitProvider.__new__(CodeCommitProvider) + provider.pr_num = 123 + provider.codecommit_client = MagicMock() + provider._get_target_contexts_for_file = MagicMock(return_value=[{ + "repository_name": "repo", + "destination_commit": "dest", + "source_commit": "src", + }]) + + suggestions = [{ + "body": "suggestion", + "relevant_file": "app.py", + "relevant_lines_start": 1, + }] + assert provider.publish_code_suggestions(suggestions) is True + provider.codecommit_client.publish_comment.assert_called_once() From d5c15c4923beb0589de5f9ec20e7580be1f15b6b Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Wed, 9 Sep 2026 16:04:23 +0800 Subject: [PATCH 014/238] fix(bitbucket): restore persistent comment links (#3226) Signed-off-by: hwan <3373484735@qq.com> --- pr_agent/git_providers/bitbucket_provider.py | 4 +++ tests/unittest/test_bitbucket_provider.py | 27 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index a6ae1ec8e6..f50157d009 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -375,6 +375,10 @@ def _get_cloud_comment(self, comment): return comment._cloud_comment return comment + def get_comment_url(self, comment): + comment = self._get_cloud_comment(comment) + return comment.data["links"]["html"]["href"] + def supports_review_comment_identity(self) -> bool: return True diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index 229749092d..3105921480 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -651,6 +651,33 @@ def test_get_issue_comments_normalizes_cloud_comments(self): assert comments[0].body == "## PR Review\n\nreview body" assert comments[0]._cloud_comment is comment + def test_persistent_review_update_links_status_to_existing_cloud_comment(self): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.pr = MagicMock() + provider.max_comment_length = 32_768 + provider.get_latest_commit_url = MagicMock(return_value="https://bitbucket.org/acme/repo/commits/head") + provider.publish_comment = MagicMock(return_value={"id": 8}) + + header = "## PR Review" + identity = PRReviewIdentity.REGULAR.value + comment_url = "https://bitbucket.org/acme/repo/pull-requests/1#comment-7" + existing = MagicMock() + existing.raw = f"{header}\n\n{identity}\n\nprevious review" + existing.data = {"links": {"html": {"href": comment_url}}} + provider.pr.comments.return_value = [existing] + + provider.publish_persistent_comment( + f"{header}\n\n{identity}\n\nupdated review", + initial_header=header, + final_update_message=True, + identity_marker=identity, + ) + + provider.publish_comment.assert_called_once_with( + f"**[Persistent review]({comment_url})** updated to latest commit " + "https://bitbucket.org/acme/repo/commits/head" + ) + class TestBitbucketServerProvider: @staticmethod def _code_suggestion(line: int): From a6484241d912309fba481b68e143dc9f9726748f Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Wed, 9 Sep 2026 16:04:31 +0800 Subject: [PATCH 015/238] fix(review): preserve findings after failed chunks (#3229) Signed-off-by: hwan <3373484735@qq.com> --- pr_agent/tools/pr_reviewer.py | 3 ++ .../test_review_large_diff_chunking.py | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 529230b862..5103a947dc 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -731,6 +731,9 @@ def _prepare_review_finding_state(self, data: dict) -> None: allow_resolution = ( bool(self.prediction) and not bool(getattr(self.incremental, "is_incremental", False)) + # A merged result with failed chunks is still partial, even when chunking left + # no additional token-budget files to report. + and not bool(self.review_failed_chunk_count) and not bool(self.remaining_files_list) and parsed.valid and current_findings is not None diff --git a/tests/unittest/test_review_large_diff_chunking.py b/tests/unittest/test_review_large_diff_chunking.py index 87b36d020a..0678d16412 100644 --- a/tests/unittest/test_review_large_diff_chunking.py +++ b/tests/unittest/test_review_large_diff_chunking.py @@ -10,6 +10,7 @@ import pytest +from pr_agent.algo.review_finding_state import ParsedReviewState, reconcile_review_findings from pr_agent.config_loader import get_settings from pr_agent.tools.pr_reviewer import PRReviewer from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -174,6 +175,41 @@ async def test_a_chunk_that_fails_does_not_lose_the_chunks_that_succeeded(chunki assert reviewer.review_failed_chunk_count == 1 +@pytest.mark.asyncio +async def test_a_failed_chunk_blocks_persistent_finding_resolution(chunking_enabled): + """A partial review must stay partial for the finding-state lifecycle too.""" + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=[RuntimeError("model refused"), CHUNK_B]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b"], [])), + ): + await reviewer._prepare_prediction("model") + + previous_state = reconcile_review_findings( + None, + [{"path": "a.py", "body": "old finding", "line_start": 3, "line_end": 4}], + allow_resolution=False, + head_sha="head-1", + timestamp="2026-09-09T00:00:00+00:00", + ).state + reviewer._review_finding_state_enabled = lambda: True + reviewer._load_review_finding_state = lambda: ParsedReviewState( + previous_state, present=True, valid=True + ) + reviewer._review_head_sha = lambda: "head-2" + reviewer._review_run_id = lambda: "run-2" + + reviewer._prepare_review_finding_state(reviewer.prediction_data) + + assert reviewer._review_state_result is not None + assert reviewer._review_state_result.resolved_ids == () + assert reviewer._review_state_result.state["last_run"]["complete"] is False + assert reviewer._review_state_result.state["findings"][0]["state"] == "ACTIVE" + + @pytest.mark.asyncio async def test_an_empty_chunk_does_not_lose_a_valid_sibling_or_trigger_fallback(chunking_enabled): reviewer = _make_reviewer() From 8bd153280bdf87776da6bae86dd7210d9e2d46c5 Mon Sep 17 00:00:00 2001 From: yashaskn8 Date: Wed, 9 Sep 2026 13:34:38 +0530 Subject: [PATCH 016/238] fix(github): report total inline suggestion fallback failure (#3232) --- pr_agent/git_providers/github_provider.py | 19 +- .../unittest/test_github_provider_comments.py | 204 ++++++++++++++++++ 2 files changed, 216 insertions(+), 7 deletions(-) diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 03993c8638..6ac7aec2d1 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -697,7 +697,7 @@ def publish_inline_comments(self, comments: list[dict], disable_fallback: bool = get_logger().info( f"Persistent inline comments: all {skipped} suggestion(s) " f"already posted; nothing to publish") - return + return True comments = deduped else: comments = [ @@ -716,6 +716,7 @@ def publish_inline_comments(self, comments: list[dict], disable_fallback: bool = for body_fp, code_fp in pending_fingerprints: store.add(body_fp) store.add(code_fp) + return True except Exception as e: get_logger().info("Initially failed to publish inline comments as committable") @@ -725,7 +726,8 @@ def publish_inline_comments(self, comments: list[dict], disable_fallback: bool = raise e # will end up with publishing the comments one by one try: - self._publish_inline_comments_fallback_with_verification(comments) + published_count = self._publish_inline_comments_fallback_with_verification(comments) + return bool(published_count) except Exception as e: get_logger().error(f"Failed to publish inline code comments fallback, error: {e}") raise @@ -890,11 +892,13 @@ def _publish_inline_comments_fallback_with_verification(self, comments: list[dic then publish all the remaining valid comments in a single review. For invalid comments, also try removing the suggestion part and posting the comment just on the first line. """ + published_count = 0 verified_comments, invalid_comments = self._verify_code_comments(comments) # publish as a group the verified comments if verified_comments: self.pr.create_review(commit=self.last_commit_id, comments=verified_comments) + published_count += len(verified_comments) # try to publish one by one the invalid comments as a one-line code comment if invalid_comments and get_settings().github.try_fix_invalid_inline_comments: @@ -902,9 +906,10 @@ def _publish_inline_comments_fallback_with_verification(self, comments: list[dic fixed_comments_as_one_liner = self._try_fix_invalid_inline_comments(invalid_comments_list) for comment in fixed_comments_as_one_liner: try: - self.publish_inline_comments([comment], disable_fallback=True) - get_logger().info(f"Published invalid comment as a single line comment: {comment}") - except: + if self.publish_inline_comments([comment], disable_fallback=True): + published_count += 1 + get_logger().info(f"Published invalid comment as a single line comment: {comment}") + except Exception: get_logger().error(f"Failed to publish invalid comment as a single line comment: {comment}") dropped_count = len(invalid_comments) - len(fixed_comments_as_one_liner) @@ -923,6 +928,7 @@ def _publish_inline_comments_fallback_with_verification(self, comments: list[dic f"Dropped {len(invalid_comments)} invalid comments " f"(try_fix_invalid_inline_comments is off). Paths: {dropped_paths}" ) + return published_count def _verify_code_comment(self, comment: dict): is_verified = False @@ -1032,8 +1038,7 @@ def publish_code_suggestions(self, code_suggestions: list) -> bool: post_parameters_list.append(post_parameters) try: - self.publish_inline_comments(post_parameters_list) - return True + return bool(self.publish_inline_comments(post_parameters_list)) except Exception as e: get_logger().error(f"Failed to publish code suggestion, error: {e}") return False diff --git a/tests/unittest/test_github_provider_comments.py b/tests/unittest/test_github_provider_comments.py index 8b686acb58..62fe1e178e 100644 --- a/tests/unittest/test_github_provider_comments.py +++ b/tests/unittest/test_github_provider_comments.py @@ -312,6 +312,7 @@ def test_publish_code_suggestions_multi_line_payload_shape(): def capture(comments, disable_fallback=False): captured["comments"] = comments + return True provider.publish_inline_comments = capture @@ -416,6 +417,209 @@ def boom(comments, disable_fallback=False): assert result is False +def test_publish_code_suggestions_422_fallback_all_dropped_returns_false(monkeypatch): + """Regression test for #3223: When the 422 fallback drops all comments + (0 verified, 0 repaired), publish_code_suggestions must return False so caller + can trigger retry logic.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + # All comments are rejected during verification + monkeypatch.setattr( + provider, + "_verify_code_comments", + lambda comments: ([], [(c, Exception("invalid")) for c in comments]), + ) + # No invalid comment can be repaired + monkeypatch.setattr( + provider, + "_try_fix_invalid_inline_comments", + lambda invalid_list: [], + ) + + suggestions = [{ + "body": "```suggestion\nsuggestion\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 10, + "relevant_lines_end": 12, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is False + # Only the initial failing create_review call occurred; 0 fallback comments posted + assert len(fake_pr.create_review_calls) == 1 + + +def test_publish_code_suggestions_422_fallback_partial_success_returns_true(monkeypatch): + """When 422 fallback successfully publishes at least one comment, return True + to prevent duplicate comment creation by whole-batch retries.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + # 1 verified comment, 1 invalid comment + def fake_verify(comments): + return [comments[0]], [(comments[1], Exception("invalid"))] + + monkeypatch.setattr(provider, "_verify_code_comments", fake_verify) + monkeypatch.setattr(provider, "_try_fix_invalid_inline_comments", lambda invalid_list: []) + + suggestions = [ + { + "body": "```suggestion\nfirst\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 1, + "relevant_lines_end": 2, + }, + { + "body": "```suggestion\nsecond\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 5, + "relevant_lines_end": 6, + }, + ] + + result = provider.publish_code_suggestions(suggestions) + assert result is True + # 1 initial failed batch call, 1 successful fallback call with the verified comment + assert len(fake_pr.create_review_calls) == 2 + assert len(fake_pr.create_review_calls[1]["comments"]) == 1 + + +def test_publish_code_suggestions_422_fallback_repaired_comment_success(monkeypatch): + """When initial batch gets 422, verification rejects, but repairing succeeds and + individual publish succeeds, return True.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + settings = SimpleNamespace( + github=SimpleNamespace(try_fix_invalid_inline_comments=True), + get=lambda key, default=None: default, + ) + monkeypatch.setattr(gh_module, "get_settings", lambda: settings) + + monkeypatch.setattr( + provider, + "_verify_code_comments", + lambda comments: ([], [(comments[0], Exception("invalid"))]), + ) + repaired = [{"body": "fixed single line", "path": "src/foo.py", "line": 10, "side": "RIGHT"}] + monkeypatch.setattr(provider, "_try_fix_invalid_inline_comments", lambda invalid: repaired) + + suggestions = [{ + "body": "```suggestion\nmulti\nline\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 10, + "relevant_lines_end": 12, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is True + # Call 1: initial batch -> raises 422 + # Call 2: repaired comment via publish_inline_comments([comment], disable_fallback=True) -> succeeds + assert len(fake_pr.create_review_calls) == 2 + assert fake_pr.create_review_calls[1]["comments"] == repaired + + +def test_publish_code_suggestions_422_fallback_repaired_comment_failure_returns_false(monkeypatch): + """When repaired payload is generated but publishing that repaired comment fails, + it must NOT count as published, and publish_code_suggestions must return False.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + settings = SimpleNamespace( + github=SimpleNamespace(try_fix_invalid_inline_comments=True), + get=lambda key, default=None: default, + ) + monkeypatch.setattr(gh_module, "get_settings", lambda: settings) + + monkeypatch.setattr( + provider, + "_verify_code_comments", + lambda comments: ([], [(comments[0], Exception("invalid"))]), + ) + repaired = [{"body": "fixed single line", "path": "src/foo.py", "line": 10, "side": "RIGHT"}] + monkeypatch.setattr(provider, "_try_fix_invalid_inline_comments", lambda invalid: repaired) + + calls = 0 + + def fail_repaired(commit=None, comments=None): + nonlocal calls + calls += 1 + if calls == 1: + raise _FakeGithubException(status=422) + # with disable_fallback=True, this re-raises from publish_inline_comments + raise _FakeGithubException(status=422) + + fake_pr.create_review = fail_repaired + + suggestions = [{ + "body": "```suggestion\nmulti\nline\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 10, + "relevant_lines_end": 12, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is False + assert calls == 2 + + +def test_publish_code_suggestions_normal_success(): + """Clean create_review call without 422 must return True.""" + fake_pr = _FakePR() + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + suggestions = [{ + "body": "normal", + "relevant_file": "src/foo.py", + "relevant_lines_start": 1, + "relevant_lines_end": 1, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is True + assert len(fake_pr.create_review_calls) == 1 + + +def test_persistent_dedup_all_skipped_returns_true(monkeypatch): + """When persistent_inline_comments is enabled and all comments are duplicates, + publish_inline_comments and publish_code_suggestions must return True without + calling create_review.""" + fake_pr = _FakePR() + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + settings = SimpleNamespace( + get=lambda key, default=None: True if key == "config.persistent_inline_comments" else default, + github=SimpleNamespace(try_fix_invalid_inline_comments=False), + ) + monkeypatch.setattr(gh_module, "get_settings", lambda: settings) + + store = MagicMock() + store.seen.return_value = True + monkeypatch.setattr(gh_module, "get_inline_comment_store", lambda prov: store) + + comments = [{"path": "src/foo.py", "body": "already posted", "line": 5}] + res_inline = provider.publish_inline_comments(comments) + assert res_inline is True + assert len(fake_pr.create_review_calls) == 0 + + suggestions = [{ + "body": "already posted", + "relevant_file": "src/foo.py", + "relevant_lines_start": 5, + "relevant_lines_end": 5, + }] + res_suggestions = provider.publish_code_suggestions(suggestions) + assert res_suggestions is True + assert len(fake_pr.create_review_calls) == 0 + + # --------------------------------------------------------------------------- # resolve_comment_thread # --------------------------------------------------------------------------- From 70d3f2769747d672f90fef9045bec17d9ca401e4 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:49:35 +0800 Subject: [PATCH 017/238] Harden Dockerfiles for stable and predictable builds (#3218) --- docker/Dockerfile | 36 ++++++++++++++++++------------------ docker/Dockerfile.lambda | 3 ++- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 11467cf521..0a3fbfc765 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,64 +1,64 @@ FROM python:3.12.14-slim AS base COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ -RUN apt update && apt install --no-install-recommends -y git curl && apt-get clean && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install --no-install-recommends -y git curl && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app ENV UV_COMPILE_BYTECODE=1 -ADD pyproject.toml uv.lock ./ -ADD docs docs +COPY pyproject.toml uv.lock ./ +COPY docs docs RUN uv sync --frozen --no-install-project --no-dev ENV PATH="/app/.venv/bin:$PATH" ENV PYTHONPATH=/app FROM base AS github_app -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "-m", "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "pr_agent/servers/gunicorn_config.py", "--forwarded-allow-ips", "*", "pr_agent.servers.github_app:app"] FROM base AS bitbucket_app -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/bitbucket_app.py"] FROM base AS bitbucket_server_webhook -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/bitbucket_server_webhook.py"] FROM base AS github_polling -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/github_polling.py"] FROM base AS github_action -ADD pr_agent pr_agent -ADD github_action/entrypoint.sh / +COPY pr_agent pr_agent +COPY github_action/entrypoint.sh / RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] FROM base AS gitlab_webhook -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "-m", "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "pr_agent/servers/gunicorn_config.py", "--forwarded-allow-ips", "*", "pr_agent.servers.gitlab_webhook:app"] FROM base AS azure_devops_webhook -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/azuredevops_server_webhook.py"] FROM base AS gitea_app -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "-m", "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "pr_agent/servers/gunicorn_config.py","pr_agent.servers.gitea_app:app"] FROM base AS mosaico_agent -ADD pr_agent pr_agent +COPY pr_agent pr_agent EXPOSE 9000 CMD ["python", "pr_agent/mosaico/server.py"] FROM base AS test RUN uv sync --frozen --no-install-project -ADD pr_agent pr_agent -ADD tests tests -ADD .github/workflows/publish.yml .github/workflows/publish.yml -ADD docker/mosaico docker/mosaico +COPY pr_agent pr_agent +COPY tests tests +COPY .github/workflows/publish.yml .github/workflows/publish.yml +COPY docker/mosaico docker/mosaico FROM base AS cli -ADD pr_agent pr_agent +COPY pr_agent pr_agent RUN uv sync --frozen --no-dev && ln -s /app/.venv/bin/pr-agent /usr/local/bin/pr-agent ENTRYPOINT ["python", "pr_agent/cli.py"] diff --git a/docker/Dockerfile.lambda b/docker/Dockerfile.lambda index bda5ee05ac..112c2de12b 100644 --- a/docker/Dockerfile.lambda +++ b/docker/Dockerfile.lambda @@ -8,7 +8,8 @@ RUN dnf update -y && \ # Build the locked environment at /opt/venv with uv; put its site-packages # on the Lambda runtime's import path alongside the task root via PYTHONPATH. ENV UV_COMPILE_BYTECODE=1 UV_PROJECT_ENVIRONMENT=/opt/venv -ADD pyproject.toml uv.lock ./ +WORKDIR ${LAMBDA_TASK_ROOT} +COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project --no-dev --extra lambda --python /var/lang/bin/python3.12 ENV PYTHONPATH="/opt/venv/lib/python3.12/site-packages:${LAMBDA_TASK_ROOT}" COPY pr_agent/ ${LAMBDA_TASK_ROOT}/pr_agent/ From aaa29921481aaa171f8b62adeeaa4fbffb3ee7e3 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:09:34 +0800 Subject: [PATCH 018/238] fix(release): keep uv.lock in sync with version bumps (#3214) --- .github/workflows/publish.yml | 16 ++++++++++------ tests/unittest/test_publish_workflow_security.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 74990eb7a2..8e51bd739d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -255,6 +255,8 @@ jobs: with: python-version: '3.12' + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - name: Flag missing PyPI publish if: needs.publish-pypi.result != 'success' env: @@ -267,8 +269,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Bump pyproject.toml - run: python scripts/set_pyproject_version.py "${{ needs.prepare.outputs.version }}" + - name: Bump pyproject.toml and sync uv.lock + run: | + python scripts/set_pyproject_version.py "${{ needs.prepare.outputs.version }}" + uv lock - name: Commit & push bump (fast-forward only) id: commit @@ -276,19 +280,19 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} PUBLISHED_SHA: ${{ needs.prepare.outputs.sha }} run: | - if git diff --quiet pyproject.toml; then - echo "pyproject.toml already at $VERSION on $PUBLISHED_SHA; nothing to commit." + if git diff --quiet pyproject.toml uv.lock; then + echo "pyproject.toml and uv.lock already at $VERSION on $PUBLISHED_SHA; nothing to commit." echo "bumped=false" >> "$GITHUB_OUTPUT" echo "sha=$PUBLISHED_SHA" >> "$GITHUB_OUTPUT" exit 0 fi - git add pyproject.toml + git add pyproject.toml uv.lock git commit -m "chore(release): bump version to $VERSION [skip ci]" if git push origin "HEAD:refs/heads/main"; then echo "bumped=true" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" else - echo "::warning::main has moved past the published commit ($PUBLISHED_SHA); skipping the bump push rather than rebasing onto a commit that was not built. Bump pyproject.toml on main manually: git checkout main && python scripts/set_pyproject_version.py $VERSION && git commit -am 'chore(release): bump version to $VERSION' && git push" + echo "::warning::main has moved past the published commit ($PUBLISHED_SHA); skipping the bump push rather than rebasing onto a commit that was not built. Bump pyproject.toml and uv.lock on main manually: git checkout main && python scripts/set_pyproject_version.py $VERSION && uv lock && git commit -am 'chore(release): bump version to $VERSION' && git push" echo "bumped=false" >> "$GITHUB_OUTPUT" echo "sha=$PUBLISHED_SHA" >> "$GITHUB_OUTPUT" fi diff --git a/tests/unittest/test_publish_workflow_security.py b/tests/unittest/test_publish_workflow_security.py index 5cdd696f0b..b97fb46e9f 100644 --- a/tests/unittest/test_publish_workflow_security.py +++ b/tests/unittest/test_publish_workflow_security.py @@ -185,3 +185,19 @@ def test_release_version_cannot_inject_workflow_outputs(tmp_path: Path, line_bre assert result.returncode != 0 assert output.read_text() == "existing-output\n" + + +def test_release_finalize_keeps_uv_lock_in_sync() -> None: + workflow = yaml.safe_load(PUBLISH_WORKFLOW.read_text()) + finalize_steps = workflow["jobs"]["finalize"]["steps"] + bump_step = next( + step for step in finalize_steps if step.get("name") == "Bump pyproject.toml and sync uv.lock" + ) + commit_step = next( + step for step in finalize_steps if step.get("name") == "Commit & push bump (fast-forward only)" + ) + + run = bump_step["run"] + assert run.index("scripts/set_pyproject_version.py") < run.index("uv lock") + assert "git diff --quiet pyproject.toml uv.lock" in commit_step["run"] + assert "git add pyproject.toml uv.lock" in commit_step["run"] From 17651f75b2b29268eb32d5d9a9227b8f3a7c1131 Mon Sep 17 00:00:00 2001 From: Aurora Date: Wed, 9 Sep 2026 17:13:29 +0200 Subject: [PATCH 019/238] refactor: select PR chat link by provider capability (#3239) Co-authored-by: Aurora --- pr_agent/git_providers/git_provider.py | 4 + pr_agent/git_providers/github_provider.py | 3 + pr_agent/tools/pr_code_suggestions.py | 7 +- .../test_pr_code_suggestions_pr_chat.py | 140 ++++++++++++++++++ 4 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 tests/unittest/test_pr_code_suggestions_pr_chat.py diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index a7fb9f0352..bb2bc57837 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -191,6 +191,10 @@ def supports_checkbox_commands(self) -> bool: support, so tools render commands as text instead.""" return False + def supports_pr_chat(self) -> bool: + """Whether this provider is compatible with the linked PR-Agent browser-extension chat experience.""" + return False + def supports_markdown_tables(self) -> bool: """Whether comments render pipe-table markdown. diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 6ac7aec2d1..4e76cab5a9 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -128,6 +128,9 @@ def supports_line_question_history(self) -> bool: def supports_checkbox_commands(self) -> bool: return True + def supports_pr_chat(self) -> bool: + return True + def _get_owner_and_repo_path(self, given_url: str) -> str: try: repo_path = None diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 38726ae31f..d1caeffc0f 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -43,10 +43,7 @@ show_run_details, ) from pr_agent.config_loader import get_settings, get_verbosity_level -from pr_agent.git_providers import ( - GithubProvider, - get_git_provider_with_context, -) +from pr_agent.git_providers import get_git_provider_with_context from pr_agent.git_providers.git_provider import GitProvider, IncrementalPR, get_main_pr_language from pr_agent.log import get_logger from pr_agent.servers.help import HelpMessage @@ -329,7 +326,7 @@ async def run(self): # add usage guide if (get_settings().pr_code_suggestions.enable_chat_text and get_settings().config.is_auto_command - and isinstance(self.git_provider, GithubProvider)): + and self.git_provider.supports_pr_chat()): pr_body += "\n\n>💡 Need additional feedback ? start a [PR chat](https://chromewebstore.google.com/detail/ephlnjeghhogofkifjloamocljapahnl) \n\n" if get_settings().pr_code_suggestions.enable_help_text: pr_body += "
\n\n
💡 Tool usage guide:
\n\n" diff --git a/tests/unittest/test_pr_code_suggestions_pr_chat.py b/tests/unittest/test_pr_code_suggestions_pr_chat.py new file mode 100644 index 0000000000..a7966a4262 --- /dev/null +++ b/tests/unittest/test_pr_code_suggestions_pr_chat.py @@ -0,0 +1,140 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.tools import pr_code_suggestions as pr_code_suggestions_module +from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +_TRACKED_SETTINGS = ( + "config.publish_output", + "config.publish_output_progress", + "config.is_auto_command", + "pr_code_suggestions.commitable_code_suggestions", + "pr_code_suggestions.demand_code_suggestions_self_review", + "pr_code_suggestions.enable_chat_text", + "pr_code_suggestions.enable_help_text", + "pr_code_suggestions.persistent_comment", + "pr_code_suggestions.dual_publishing_score_threshold", +) + + +class _CustomProvider(GitProvider): + """Minimal external provider that inherits the default chat capability.""" + + def is_supported(self, capability: str) -> bool: + return False + + def get_files(self) -> list: + return [] + + def get_diff_files(self) -> list: + return [] + + def publish_description(self, pr_title: str, pr_body: str): + pass + + def publish_code_suggestions(self, code_suggestions: list) -> bool: + return False + + def get_languages(self): + return {} + + def get_pr_branch(self): + return "" + + def get_user_id(self): + return "" + + def get_pr_description_full(self) -> str: + return "" + + def get_repo_settings(self): + return b"" + + def publish_comment(self, pr_comment: str, is_temporary: bool = False): + pass + + def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): + pass + + def publish_inline_comments(self, comments: list[dict]): + pass + + def remove_initial_comment(self): + pass + + def remove_comment(self, comment): + pass + + def get_issue_comments(self): + return [] + + def publish_labels(self, labels): + pass + + def get_pr_labels(self, update=False): + return [] + + def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: + return False + + def get_commit_messages(self) -> str: + return "" + + + +def test_git_provider_supports_pr_chat_defaults_to_false(): + assert _CustomProvider().supports_pr_chat() is False + + +def test_github_provider_supports_pr_chat_is_true(): + assert GithubProvider.supports_pr_chat(MagicMock()) is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("supports_pr_chat", "expect_link"), [(True, True), (False, False)]) +async def test_pr_chat_link_depends_on_provider_capability(monkeypatch, supports_pr_chat, expect_link): + snapshot = snapshot_settings(_TRACKED_SETTINGS) + try: + provider = MagicMock() + provider.get_files.return_value = ["file.py"] + provider.is_supported.side_effect = lambda capability: capability == "gfm_markdown" + provider.supports_pr_chat.return_value = supports_pr_chat + provider.should_publish_improve_as_thread.return_value = False + + tool = PRCodeSuggestions.__new__(PRCodeSuggestions) + tool.pr_url = "https://example.invalid/pr/1" + tool.progress = "progress" + tool.progress_response = None + tool.git_provider = provider + tool.incremental = SimpleNamespace(is_incremental=False) + tool.generate_summarized_suggestions = MagicMock(return_value="## Suggestions") + + async def _fake_retry(*_args, **_kwargs): + return {"code_suggestions": [{"label": "style", "suggestion_content": "clean up"}]} + + monkeypatch.setattr(pr_code_suggestions_module, "retry_with_fallback_models", _fake_retry) + + settings = get_settings() + settings.config.publish_output = True + settings.config.publish_output_progress = False + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.demand_code_suggestions_self_review = False + settings.pr_code_suggestions.enable_chat_text = True + settings.pr_code_suggestions.enable_help_text = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.dual_publishing_score_threshold = 0 + + await tool.run() + + published_body = provider.publish_comment.call_args.args[0] + provider.supports_pr_chat.assert_called_once_with() + assert ("start a [PR chat]" in published_body) is expect_link + finally: + restore_settings(snapshot) From 53072488e4c3b5a6c9ae730fe6fb52fc5f09d06c Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:20:26 +0800 Subject: [PATCH 020/238] ci: verify uv.lock freshness (#3245) --- .github/workflows/build-and-test.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 16f76f89ed..59a3d46dc7 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -16,6 +16,12 @@ jobs: - id: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - id: uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Check uv.lock + run: uv lock --check + - id: dockerx name: Setup Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 From 1f1abd1258221703391392cc12a3e4d9abb8ff4f Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:09:55 +0800 Subject: [PATCH 021/238] perf(gitlab): avoid redundant project fetches for file reads (#3247) --- pr_agent/git_providers/gitlab_provider.py | 2 +- .../test_gitlab_file_read_efficiency.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 tests/unittest/test_gitlab_file_read_efficiency.py diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 33553d880f..ebb2ba57f5 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -732,7 +732,7 @@ def _get_own_user_id(self) -> Optional[int]: def get_pr_file_content(self, file_path: str, branch: str) -> str: try: - file_obj = self.gl.projects.get(self.id_project).files.get(file_path, branch) + file_obj = self.gl.projects.get(self.id_project, lazy=True).files.get(file_path, branch) content = file_obj.decode() return decode_if_bytes(content) except GitlabGetError: diff --git a/tests/unittest/test_gitlab_file_read_efficiency.py b/tests/unittest/test_gitlab_file_read_efficiency.py new file mode 100644 index 0000000000..6353c61331 --- /dev/null +++ b/tests/unittest/test_gitlab_file_read_efficiency.py @@ -0,0 +1,22 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from pr_agent.git_providers.gitlab_provider import GitLabProvider + + +def test_get_pr_file_content_uses_lazy_project_handle(): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/repo" + + file_obj = MagicMock() + file_obj.decode.return_value = b"hello\n" + project = MagicMock() + project.files.get.return_value = file_obj + projects = MagicMock() + projects.get.return_value = project + provider.gl = SimpleNamespace(projects=projects) + + assert provider.get_pr_file_content("src/app.py", "deadbeef") == "hello\n" + + projects.get.assert_called_once_with("group/repo", lazy=True) + project.files.get.assert_called_once_with("src/app.py", "deadbeef") From 539d0b8c9937c68085a2d1cc774e1dc3cc414854 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:35:42 +0545 Subject: [PATCH 022/238] refactor: remove 14 dead functions in the tool layer (#3182) (#3236) --- pr_agent/algo/utils.py | 103 +------------------ pr_agent/tools/pr_code_suggestions.py | 9 -- pr_agent/tools/pr_description.py | 29 ------ pr_agent/tools/pr_help_message.py | 33 ------ pr_agent/tools/pr_reviewer.py | 23 ----- pr_agent/tools/ticket_pr_compliance_check.py | 4 - 6 files changed, 1 insertion(+), 200 deletions(-) diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 5b0d4cf175..c74e144868 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -2,7 +2,6 @@ import copy import difflib -import hashlib import html import json import os @@ -10,8 +9,6 @@ import string import sys import textwrap -import time -import traceback from datetime import datetime, timezone from decimal import ROUND_HALF_UP, Decimal from enum import Enum @@ -23,7 +20,6 @@ import requests import yaml from pydantic import BaseModel -from starlette_context import context from pr_agent.algo import MAX_TOKENS from pr_agent.algo.git_patch_processing import ( @@ -34,7 +30,7 @@ from pr_agent.algo.run_details import get_run_details from pr_agent.algo.token_handler import TokenEncoder from pr_agent.algo.types import FilePatchInfo -from pr_agent.config_loader import get_settings, get_verbosity_level, global_settings +from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.log import get_logger _ENCODED_USER_TEXT_PREFIX = "__pr_agent_encoded_text__:" @@ -223,14 +219,6 @@ class PRDescriptionHeader(str, Enum): FILE_WALKTHROUGH = "File Walkthrough" -def get_setting(key: str) -> Any: - try: - key = key.upper() - return context.get("settings", global_settings).get(key, global_settings.get(key, None)) - except Exception: - return global_settings.get(key, None) - - def as_review_text(value) -> str: """Flatten a review field the model returned as a list or mapping into readable text. @@ -275,18 +263,6 @@ def emphasize_header(text: str, only_markdown=False, reference_link=None) -> str return text -def unique_strings(input_list: List[str]) -> List[str]: - if not input_list or not isinstance(input_list, list): - return input_list - seen = set() - unique_list = [] - for item in input_list: - if item not in seen: - unique_list.append(item) - seen.add(item) - return unique_list - - def _expand_minute_suffix(text: str) -> str: """Replace minute abbreviations like '30m' with '30 minutes'. @@ -1645,64 +1621,6 @@ def scan_patch_lines(is_match): break return position, absolute_position -def get_rate_limit_status(github_token) -> dict: - GITHUB_API_URL = get_settings(use_context=False).get("GITHUB.BASE_URL", "https://api.github.com").rstrip("/") # "https://api.github.com" - # GITHUB_API_URL = "https://api.github.com" - RATE_LIMIT_URL = f"{GITHUB_API_URL}/rate_limit" - HEADERS = { - "Accept": "application/vnd.github.v3+json", - "Authorization": f"token {github_token}" - } - - response = requests.get(RATE_LIMIT_URL, headers=HEADERS) - try: - rate_limit_info = response.json() - if rate_limit_info.get('message') == 'Rate limiting is not enabled.': # for github enterprise - return {'resources': {}} - response.raise_for_status() # Check for HTTP errors - except: # retry - time.sleep(0.1) - response = requests.get(RATE_LIMIT_URL, headers=HEADERS) - return response.json() - return rate_limit_info - - -def validate_rate_limit_github(github_token, installation_id=None, threshold=0.1) -> bool: - try: - rate_limit_status = get_rate_limit_status(github_token) - if installation_id: - get_logger().debug(f"installation_id: {installation_id}, Rate limit status: {rate_limit_status['rate']}") - # validate that the rate limit is not exceeded - # validate that the rate limit is not exceeded - for key, value in rate_limit_status['resources'].items(): - if value['remaining'] < value['limit'] * threshold: - get_logger().error(f"key: {key}, value: {value}") - return False - return True - except Exception as e: - get_logger().error(f"Error in rate limit {e}", - artifact={"traceback": traceback.format_exc()}) - return True - - -def validate_and_await_rate_limit(github_token): - try: - rate_limit_status = get_rate_limit_status(github_token) - # validate that the rate limit is not exceeded - for key, value in rate_limit_status['resources'].items(): - if value['remaining'] < value['limit'] // 80: - get_logger().error(f"key: {key}, value: {value}") - sleep_time_sec = value['reset'] - datetime.now().timestamp() - sleep_time_hour = sleep_time_sec / 3600.0 - get_logger().error(f"Rate limit exceeded. Sleeping for {sleep_time_hour} hours") - if sleep_time_sec > 0: - time.sleep(sleep_time_sec + 1) - rate_limit_status = get_rate_limit_status(github_token) - return rate_limit_status - except: - get_logger().error("Error in rate limit") - return None - def github_action_output(output_data: dict, key_name: str): try: @@ -1899,25 +1817,6 @@ def is_value_no(value): return False -def set_pr_string(repo_name, pr_number): - return f"{repo_name}#{pr_number}" - - -def string_to_uniform_number(s: str) -> float: - """ - Convert a string to a uniform number in the range [0, 1]. - The uniform distribution is achieved by the nature of the SHA-256 hash function, which produces a uniformly distributed hash value over its output space. - """ - # Generate a hash of the string - hash_object = hashlib.sha256(s.encode()) - # Convert the hash to an integer - hash_int = int(hash_object.hexdigest(), 16) - # Normalize the integer to the range [0, 1] - max_hash_int = 2 ** 256 - 1 - uniform_number = float(hash_int) / max_hash_int - return uniform_number - - def process_description(description_full: str) -> Tuple[str, List]: if not description_full: return "", [] diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index d1caeffc0f..af971fde92 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -784,15 +784,6 @@ def _with_identity(comment_text: str) -> str: new_comment = git_provider.publish_comment(pr_comment, **({"as_thread": True} if as_thread else {})) return new_comment - def extract_link(self, s): - r = re.compile(r"") - match = r.search(s) - - up_to_commit_txt = "" - if match: - up_to_commit_txt = f" up to commit {match.group(0)[4:-3].strip()}" - return up_to_commit_txt - async def _prepare_prediction(self, model: str) -> dict: self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index ecb18c617e..00585a105b 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -543,35 +543,6 @@ async def extend_uncovered_files(self, original_prediction: str) -> str: return original_prediction - async def extend_additional_files(self, remaining_files_list) -> str: - prediction = self.prediction - try: - original_prediction_dict = load_yaml(self.prediction, keys_fix_yaml=self.keys_fix) - prediction_extra = "pr_files:" - for file in remaining_files_list: - extra_file_yaml = f"""\ -- filename: | - {file} - changes_summary: | - ... - changes_title: | - ... - label: | - additional files (token-limit) -""" - prediction_extra = prediction_extra + "\n" + extra_file_yaml.strip() - prediction_extra_dict = load_yaml(prediction_extra, keys_fix_yaml=self.keys_fix) - # merge the two dictionaries - if isinstance(original_prediction_dict, dict) and isinstance(prediction_extra_dict, dict): - original_prediction_dict["pr_files"].extend(prediction_extra_dict["pr_files"]) - new_yaml = yaml.dump(original_prediction_dict) - if load_yaml(new_yaml, keys_fix_yaml=self.keys_fix): - prediction = new_yaml - return prediction - except Exception as e: - get_logger().error(f"Error extending additional files {self.pr_id}: {e}") - return self.prediction - async def _get_prediction(self, model: str, patches_diff: str, prompt="pr_description_prompt") -> str: variables = copy.deepcopy(self.vars) variables["diff"] = patches_diff # update diff diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 54dc980346..1d909617ad 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -17,19 +17,6 @@ from pr_agent.log import get_logger -def extract_header(snippet): - res = '' - lines = snippet.split('===Snippet content===')[0].split('\n') - highest_header = '' - highest_level = float('inf') - for line in lines[::-1]: - line = line.strip() - if line.startswith('Header '): - highest_header = line.split(': ')[1] - if highest_header: - res = f"#{highest_header.lower().replace(' ', '-')}" - return res - class PRHelpMessage: def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler, return_as_string=False): self.git_provider = get_git_provider_with_context(pr_url) @@ -267,26 +254,6 @@ async def run(self): get_logger().exception(f"Error while running PRHelpMessage: {e}") return "" - async def prepare_relevant_snippets(self, sim_results): - # Get relevant snippets - relevant_snippets_full = [] - relevant_pages_full = [] - relevant_snippets_full_header = [] - th = 0.75 - for s in sim_results: - page = s[0].metadata['source'] - content = s[0].page_content - score = s[1] - relevant_snippets_full.append(content) - relevant_snippets_full_header.append(extract_header(content)) - relevant_pages_full.append(page) - # build the snippets string - relevant_snippets_str = "" - for i, s in enumerate(relevant_snippets_full): - relevant_snippets_str += f"Snippet {i+1}:\n\n{s}\n\n" - relevant_snippets_str += "-------------------\n\n" - return relevant_pages_full, relevant_snippets_full_header, relevant_snippets_str - def generate_bbdc_table(column_arr_1, column_arr_2): # Generating header row diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 5103a947dc..3a365fd097 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -1189,29 +1189,6 @@ def _get_user_answers(self) -> Tuple[str, str]: return question_str, answer_str - def _get_previous_review_comment(self): - """ - Get the previous review comment if it exists. - """ - try: - if hasattr(self.git_provider, "get_previous_review"): - return self.git_provider.get_previous_review( - full=not self.incremental.is_incremental, - incremental=self.incremental.is_incremental, - ) - except Exception as e: - get_logger().exception(f"Failed to get previous review comment, error: {e}") - - def _remove_previous_review_comment(self, comment): - """ - Remove the previous review comment if it exists. - """ - try: - if comment: - self.git_provider.remove_comment(comment) - except Exception as e: - get_logger().exception(f"Failed to remove previous review comment, error: {e}") - def _can_run_incremental_review(self) -> bool: """ Checks if we can run incremental review according the various configurations and previous review. diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 797439aeec..38e25d7b1e 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -673,7 +673,3 @@ async def extract_and_cache_pr_tickets(git_provider, vars): else: get_logger().info("Using cached tickets", artifact={"tickets": related_tickets}) vars['related_tickets'] = related_tickets - - -def check_tickets_relevancy(): - return True From 2296abb2980168aaaa486dd66726176a6024882b Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:35:53 +0545 Subject: [PATCH 023/238] docs: add a documentation page for the MOSAICO A2A server (#3234) --- docker/mosaico/README.md | 4 +- docs/docs/installation/mosaico_server.md | 132 +++++++++++++++++++++++ docs/docs/summary.md | 1 + docs/mkdocs.yml | 1 + 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 docs/docs/installation/mosaico_server.md diff --git a/docker/mosaico/README.md b/docker/mosaico/README.md index 99f7be1884..47234847d2 100644 --- a/docker/mosaico/README.md +++ b/docker/mosaico/README.md @@ -4,13 +4,13 @@ Deployment assets for running **PR-Agent** as a [MOSAICO](https://mosaico-projec *solution agent*. This directory contains no Python and no pr-agent source — it consumes PR-Agent as a published, version-pinned Docker image. The agent's source lives in [`The-PR-Agent/pr-agent`](https://github.com/The-PR-Agent/pr-agent), under `pr_agent/mosaico/`; -it is merged into `main` and ships in every release wheel and image starting at `v0.37.0`. +it is merged into `main` and ships in every release wheel and image starting at `v0.36.0`. ## Relationship to the PR-Agent repository This is not a fork and it never becomes one: -- The MOSAICO A2A server is PR-Agent code, released in tags `v0.37.0` onwards. This bundle +- The MOSAICO A2A server is PR-Agent code, released in tags `v0.36.0` onwards. This bundle holds zero Python — only a compose overlay, a registration template, an env template, a smoke test, and this README. - **Staying current is one line**: bump the pinned tag in `docker-compose.pr-agent.yml`, then diff --git a/docs/docs/installation/mosaico_server.md b/docs/docs/installation/mosaico_server.md new file mode 100644 index 0000000000..f4e3b04275 --- /dev/null +++ b/docs/docs/installation/mosaico_server.md @@ -0,0 +1,132 @@ +## MOSAICO A2A server + +PR-Agent can run as an [A2A](https://a2a-protocol.org/) 1.0 *solution agent* for the +[MOSAICO](https://mosaico-project.eu/) ecosystem: a small Starlette server that exposes the +standard A2A surface (agent card + JSON-RPC) plus a health probe. It is **not** a fork or a +separate project — the server is PR-Agent code under [`pr_agent/mosaico/`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/mosaico/server.py), +ships in every release wheel, and ships as its own Docker image (`-mosaico_agent`) +starting at `v0.36.0`. The server is unbiased about the git provider: every request carries +either a PR URL or a raw diff, and the agent answers from that input alone. + +### What the mode is + +The A2A server exposes three endpoints: + +| Path | Method | Purpose | +| --- | --- | --- | +| `/.well-known/agent-card.json` | GET | The A2A agent card | +| `/` | POST | A2A 1.0 JSON-RPC. Sends the `SendMessage` method with an `A2A-Version: 1.0` header (the header is required: the server treats a request without it as protocol 0.3 and rejects it). The reply arrives as a task artifact (`result.task.artifacts[].parts[].text`), not as a status message. | +| `/health` | GET | A **live LLM connectivity probe** — `200` when an LLM round-trip succeeds, `503` otherwise | + +The advertised agent card carries the skills `review`, `improve`, `describe`, and `ask`, the +name `"PR-Agent Solution Agent"`, a `version` derived from the running build (never +hand-maintained), and the required +`https://mosaico-project.eu/extensions/mosaico-observability` extension. Streaming is +advertised as disabled, which is load-bearing: the reference agent selects +`message/send` vs `message/stream` from that capability. + +### Run the standalone container + +The server boots from a bare `docker pull` in a couple of seconds — no repo clone, no build: + +```bash +docker pull pragent/pr-agent:0.41.0-mosaico_agent +docker run -d --name pr-agent-mosaico -p 9000:9000 \ + -e API_BASE=https://your-openai-compatible-endpoint/v1 \ + -e API_KEY=sk-... \ + -e MODEL_NAME=openai/your-model-slug \ + pragent/pr-agent:0.41.0-mosaico_agent + +curl -s http://localhost:9000/.well-known/agent-card.json | python3 -m json.tool +``` + +Pin a version tag in production (see the "Immutable releases and version tags" note on the +[installation page](./index.md)); the plain `mosaico_agent` +rolling tag moves to the newest build on every release. + +### Environment variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `API_BASE` | — | Base URL of the OpenAI-compatible LLM endpoint | +| `API_KEY` | — | API key for that endpoint | +| `MODEL_NAME` | — | Model slug to call | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `9000` | Bind port | +| `AGENT_CARD_HOST`, `AGENT_CARD_PORT` | unset | URL advertised in the card's `supportedInterfaces`; see the warning below | +| `MODEL_MAX_TOKENS` | `32000` | Token budget for models whose context size pr-agent does not already know | +| `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` | unset | Optional Langfuse observability | + +!!! warning "AGENT_CARD_HOST / AGENT_CARD_PORT — the one thing to get right" + + These two variables set the URL the agent advertises in `supportedInterfaces`. Leave them + unset and the card advertises `http://localhost:9000/`, which is reachable only from + inside the container itself. The failure this causes is **silent and late**: registration + with MOSAICO succeeds, the repository stores the unreachable URL, and the reference agent + only fails to dereference it once it tries to route a task to this agent. Set them to + whatever host/port the *caller* will use to reach the container, and verify with: + + ```bash + curl -s http://:/.well-known/agent-card.json \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['supportedInterfaces'][0]['url'])" + ``` + + If that prints a `localhost` URL, the deployment is wrong. + +### Deploy into the mosaico-demonstrator + +The [`docker/mosaico/`](https://github.com/the-pr-agent/pr-agent/tree/main/docker/mosaico) +directory is a full deployment bundle (compose overlay, registration template, env template, +smoke test, LICENSE, and the canonical README). To run the agent as a task agent in the +[mosaico-demonstrator](https://gitlab.eclipse.org/eclipse-research-labs/mosaico-project/mosaico-demonstrator): + +1. Copy `docker-compose.pr-agent.yml` into the demonstrator's `compose/` directory, next to + `base-definitions.yml` (the overlay's `extends:` references resolve relative to that + directory). +2. Copy `pr-agent-solution-agent.json` into the demonstrator's + `docker/agent-registrations/` directory. +3. Append the "demonstrator overlay" block from `pr-agent.env.example` to the demonstrator's + `env/llm.env` and fill in `PR_AGENT_MODEL` (`PR_AGENT_HOST` may stay empty to use the + demonstrator's auto-detected LAN IP; `PR_AGENT_PORT` defaults to `23000`). +4. Add `-f compose/docker-compose.pr-agent.yml` to the demonstrator's `01-compose.sh`, next to + the other task-agent overlays. +5. Run `./01-compose.sh up -d`. + +The registration template carries only `description`, `role`, `objective`, `version`; the +demonstrator's `register-agent.py` injects `name`, `a2aAgentCardUrl`, and +`deployment.mode = ENDPOINT` at registration time. Two names are intentionally different and +should not be "fixed": the repository entry is `pr-agent-solution-agent` (what +`register-agent.py` looks the agent up by), while the card's own `name` is +`"PR-Agent Solution Agent"` (a display string). + +### Verify + +```bash +./smoke_test.sh +``` + +in the bundle directory gives one of two outcomes: + +- **`SMOKE PASSED`** — no LLM credentials were available; the script pulled the pinned image, + booted it, and validated the agent card only. +- **`FULL ROUND-TRIP PASSED`** — credentials were present (via a `.env` file beside the script, + copied from `pr-agent.env.example`); the script additionally exercised `GET /health` and an + A2A `SendMessage` review over an inline diff. + +### Troubleshooting + +- **The container stays `unhealthy` and registration never runs.** `/health` is a live LLM + probe and returns `503` on bad or missing credentials — this is intended. Check + `API_BASE` / `API_KEY` / `MODEL_NAME`, not the compose file. +- **The agent registers but the reference agent never reaches it.** The advertised card URL is + `localhost`; see the `AGENT_CARD_HOST` / `AGENT_CARD_PORT` warning above. +- **The registration container itself cannot fetch the agent card.** `01-compose.sh` falls back + to `get_fallback_ip`, which can resolve to `localhost` — reachable from the host but not from + inside the registration container on the Docker network. Set `PR_AGENT_HOST` explicitly to an + address reachable from inside Docker (for example the host's LAN IP, or `host.docker.internal`). + +### Keep reading + +The [bundle README](https://github.com/the-pr-agent/pr-agent/blob/main/docker/mosaico/README.md) +is the canonical deep dive for this surface and the source of the summary above; it covers the +upgrade procedure, the registration flow, and the full env-var contract in one place. diff --git a/docs/docs/summary.md b/docs/docs/summary.md index b4e71bb2f8..b84041b4c1 100644 --- a/docs/docs/summary.md +++ b/docs/docs/summary.md @@ -13,6 +13,7 @@ * [BitBucket Integration](installation/bitbucket.md) * [Azure DevOps Integration](installation/azure.md) * [Gitea Integration](installation/gitea.md) +* [MOSAICO A2A Server](installation/mosaico_server.md) ## Usage Guide diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 98774f1352..536eb5c453 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -15,6 +15,7 @@ nav: - BitBucket Integration: 'installation/bitbucket.md' - Azure DevOps Integration: 'installation/azure.md' - Gitea Integration: 'installation/gitea.md' + - MOSAICO A2A Server: 'installation/mosaico_server.md' - Usage Guide: - 'usage-guide/index.md' - Introduction: 'usage-guide/introduction.md' From 3abfc84bc677faad1e5354437c2625cf920a9adc Mon Sep 17 00:00:00 2001 From: Vladimir Roshchin <51440383+even-even@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:51:03 +0300 Subject: [PATCH 024/238] chore: optimize list(x.values) -> x.to_list() pandas (#3233) Co-authored-by: even-even <> --- pr_agent/tools/pr_similar_issue.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index c952628384..ec963fa28f 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -512,7 +512,7 @@ def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=Fal get_logger().info('Done') get_logger().info('Embedding...') - list_to_encode = list(df["text"].values) + list_to_encode = df["text"].to_list() embeds = _embed_with_fallback(list_to_encode) df["values"] = embeds meta = DatasetMetadata.empty() @@ -598,7 +598,7 @@ def _update_table_with_issues(self, issues_list, repo_name_for_index, ingest=Fal get_logger().info('Done') get_logger().info('Embedding...') - list_to_encode = list(df["text"].values) + list_to_encode = df["text"].to_list() embeds = _embed_with_fallback(list_to_encode) df["vector"] = embeds get_logger().info('Done') @@ -685,7 +685,7 @@ def _update_qdrant_with_issues(self, issues_list, repo_name_for_index, ingest=Fa get_logger().info('Done') get_logger().info('Embedding...') - list_to_encode = list(df["text"].values) + list_to_encode = df["text"].to_list() embeds = _embed_with_fallback(list_to_encode) df["vector"] = embeds get_logger().info('Done') From 847dcbb80eb99b6c0b0fc5ae730efc25a023fd8c Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:36:14 +0545 Subject: [PATCH 025/238] chore(github): upgrade PyGithub to 2.x and migrate off deprecated auth classes (#3240) --- pr_agent/git_providers/github_provider.py | 35 ++++++-- pr_agent/settings/configuration.toml | 7 ++ pyproject.toml | 2 +- tests/unittest/test_github_app_auth.py | 80 ++++++++++++++++++- ...st_review_persistent_comment_authorship.py | 8 +- uv.lock | 11 +-- 6 files changed, 124 insertions(+), 19 deletions(-) diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 4e76cab5a9..b5a1758efc 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -11,7 +11,7 @@ from typing import Optional, Tuple from urllib.parse import urlparse -from github import AppAuthentication, Auth, Github, GithubException, GithubIntegration +from github import Auth, Github, GithubException, GithubIntegration, GithubRetry from github.Issue import Issue from retry.api import retry_call from starlette_context import context @@ -466,8 +466,10 @@ def _resolve_app_login(self) -> str: return cached try: integration = GithubIntegration( - integration_id=str(get_settings().github.app_id), - private_key=get_settings().github.private_key, + auth=Auth.AppAuth( + app_id=str(get_settings().github.app_id), + private_key=get_settings().github.private_key, + ), base_url=self.base_url, ) slug = (getattr(integration.get_app(), "slug", "") or "").strip() @@ -1379,15 +1381,18 @@ def _get_github_client(self): if self.deployment_type == 'app': try: private_key = get_settings().github.private_key - # The app id is an integer in the settings toml, but PyJWT >=2.11 requires a - # string `iss` claim, and PyGithub 1.59 passes it through raw (#2955). + # The app id is an integer in the settings toml. PyJWT >=2.11 requires a + # string `iss` claim; PyGithub 2.7+ normalizes an int app id to a string + # upstream (#2955, PyGithub#3272), so the cast is harmless on the 2.10 pin. app_id = str(get_settings().github.app_id) except AttributeError as e: raise ValueError("GitHub app ID and private key are required when using GitHub app deployment") from e if not self.installation_id: raise ValueError("GitHub app installation ID is required when using GitHub app deployment") - auth = AppAuthentication(app_id=app_id, private_key=private_key, - installation_id=self.installation_id) + auth = Auth.AppInstallationAuth( + Auth.AppAuth(app_id=app_id, private_key=private_key), + installation_id=self.installation_id, + ) self.auth = auth elif self.deployment_type == 'user': try: @@ -1398,7 +1403,21 @@ def _get_github_client(self): "https://github.com/Codium-ai/pr-agent#method-2-run-from-source") from e self.auth = Auth.Token(token) if self.auth: - return Github(auth=self.auth, base_url=self.base_url) + github_config = get_settings().github + # PyGithub 2.x defaults to pacing and retries (0.25s between requests, 1s between + # writes, 10 retries); these had no equivalent on 1.59. The settings mirror the + # 1.59 behaviour, so the upgrade stays behaviour-neutral unless an operator opts in. + seconds_between_requests = github_config.get("seconds_between_requests", 0) + seconds_between_writes = github_config.get("seconds_between_writes", 0) + api_retries = github_config.get("api_retries", 0) + retry = GithubRetry(total=api_retries) if api_retries else None + return Github( + auth=self.auth, + base_url=self.base_url, + seconds_between_requests=seconds_between_requests, + seconds_between_writes=seconds_between_writes, + retry=retry, + ) else: raise ValueError("Could not authenticate to GitHub") diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index a1ac964f58..74ffd34b2d 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -290,6 +290,13 @@ enable_help_text=false # The type of deployment to create. Valid values are 'app' or 'user'. deployment_type = "user" ratelimit_retries = 5 +# PyGithub 2.x enables request pacing and transient retries by default (0.25s +# between requests, 1s between writes, 10 retries); PyGithub 1.59 applied none. +# The defaults below mirror the 1.59 behaviour, so the 2.x upgrade stays neutral +# unless an operator opts in. Raise them to throttle or retry on the GitHub API. +seconds_between_requests = 0 # seconds between API requests; 0 = no pacing (1.59 behaviour) +seconds_between_writes = 0 # seconds between write calls; 0 = no pacing (1.59 behaviour) +api_retries = 0 # max retries per request with backoff; 0 = no retries (1.59 behaviour) polling_request_timeout = 10 # total seconds for comment-history fallback; positive values capped at 60 base_url = "https://api.github.com" publish_inline_comments_fallback_with_verification = true diff --git a/pyproject.toml b/pyproject.toml index d04271fbc6..49441158bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "loguru==0.7.2", "msrest==0.7.1", "openai>=1.55.3", - "PyGithub==1.59.*", + "PyGithub>=2.10,<3", "PyJWT>=2.13.0,<3", "PyYAML==6.0.1", "python-gitlab==8.3.0", diff --git a/tests/unittest/test_github_app_auth.py b/tests/unittest/test_github_app_auth.py index 58e1820953..f3761d7574 100644 --- a/tests/unittest/test_github_app_auth.py +++ b/tests/unittest/test_github_app_auth.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -8,10 +10,12 @@ class TestGithubAppAuth: def test_integer_app_id_produces_valid_jwt(self): - """GitHub App ids are integers in the settings toml, but PyJWT >=2.11 rejects a - non-string `iss` claim and PyGithub 1.59 passes the id through raw (#2955, - previously #2210; fixed upstream in PyGithub#3272, which we don't ship yet). - The provider must cast to str before building the authentication. + """GitHub App ids are integers in the settings toml. PyJWT >=2.11 rejects a + non string `iss` claim and PyGithub 1.59 passed the id through raw (#2955, + previously #2210; upstream fixed the pass through in PyGithub#3272, which + landed in 2.7.0, so the 2.10 pin ships it). The provider keeps casting to + str before building the authentication, which is thus harmless on the 2.10 + pin while it stays a hard requirement for the older 1.59 behaviour. """ key = rsa.generate_private_key(public_exponent=65537, key_size=2048) private_key_pem = key.private_bytes( @@ -46,6 +50,74 @@ def test_integer_app_id_produces_valid_jwt(self): for name, value in original.items(): settings.set(name, value) + def test_client_defaults_to_no_pacing_and_no_retry(self, monkeypatch): + """PyGithub 2.x turns on pacing and 10 retries by default; PR-Agent must + stay behaviour-neutral unless configured. The [github] settings mirror the + 1.59 defaults and the client constructor applies them explicitly + (https://github.com/The-PR-Agent/pr-agent/pull/3240).""" + settings = get_settings() + original = { + "GITHUB.DEPLOYMENT_TYPE": settings.get("GITHUB.DEPLOYMENT_TYPE", None), + "GITHUB.USER_TOKEN": settings.get("GITHUB.USER_TOKEN", None), + } + settings.set("GITHUB.DEPLOYMENT_TYPE", "user") + settings.set("GITHUB.USER_TOKEN", "stub-token") + captured = {} + + def fake_github(**kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr("pr_agent.git_providers.github_provider.Github", fake_github) + provider = GithubProvider.__new__(GithubProvider) + provider.installation_id = None + provider.base_url = "https://api.github.com" + try: + provider._get_github_client() + finally: + for name, value in original.items(): + settings.set(name, value) + + assert captured["seconds_between_requests"] == 0 + assert captured["seconds_between_writes"] == 0 + assert captured["retry"] is None + + def test_pacing_and_retry_settings_reach_the_client(self, monkeypatch): + """Operators may opt into PyGithub 2.x throttling and backoff via the + [github] settings; each knob must land on the constructor (PR #3240).""" + settings = get_settings() + original = { + "GITHUB.DEPLOYMENT_TYPE": settings.get("GITHUB.DEPLOYMENT_TYPE", None), + "GITHUB.USER_TOKEN": settings.get("GITHUB.USER_TOKEN", None), + "GITHUB.SECONDS_BETWEEN_REQUESTS": settings.get("GITHUB.SECONDS_BETWEEN_REQUESTS", None), + "GITHUB.SECONDS_BETWEEN_WRITES": settings.get("GITHUB.SECONDS_BETWEEN_WRITES", None), + "GITHUB.API_RETRIES": settings.get("GITHUB.API_RETRIES", None), + } + settings.set("GITHUB.DEPLOYMENT_TYPE", "user") + settings.set("GITHUB.USER_TOKEN", "stub-token") + settings.set("GITHUB.SECONDS_BETWEEN_REQUESTS", 0.25) + settings.set("GITHUB.SECONDS_BETWEEN_WRITES", 1.0) + settings.set("GITHUB.API_RETRIES", 4) + captured = {} + + def fake_github(**kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr("pr_agent.git_providers.github_provider.Github", fake_github) + provider = GithubProvider.__new__(GithubProvider) + provider.installation_id = None + provider.base_url = "https://api.github.com" + try: + provider._get_github_client() + finally: + for name, value in original.items(): + settings.set(name, value) + + assert captured["seconds_between_requests"] == 0.25 + assert captured["seconds_between_writes"] == 1.0 + assert captured["retry"].total == 4 + def test_is_bot_user_reads_github_section_setting(self): """is_bot_user must honor `ignore_bot_pr` from the [github] section (#3017): configuration.toml sets it under [github], so reading diff --git a/tests/unittest/test_review_persistent_comment_authorship.py b/tests/unittest/test_review_persistent_comment_authorship.py index f457ab2467..00a073df1d 100644 --- a/tests/unittest/test_review_persistent_comment_authorship.py +++ b/tests/unittest/test_review_persistent_comment_authorship.py @@ -17,7 +17,7 @@ from unittest.mock import MagicMock import pytest -from github import GithubException +from github import Auth, GithubException from pr_agent.algo.review_finding_state import append_review_state, reconcile_review_findings from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo @@ -367,9 +367,11 @@ def test_a_transient_app_login_failure_is_retried(monkeypatch): monkeypatch.setattr(get_settings(), "github", SimpleNamespace( app_id="1", private_key="key", deployment_type="app"), raising=False) attempts = [] + auths = [] def integration(**kwargs): attempts.append(1) + auths.append(kwargs["auth"]) if len(attempts) == 1: raise RuntimeError("connection reset") return SimpleNamespace(get_app=lambda: SimpleNamespace(slug="pr-agent")) @@ -379,6 +381,7 @@ def integration(**kwargs): assert provider._resolve_app_login() == "" assert provider._resolve_app_login() == "pr-agent[bot]" assert len(attempts) == 2 + assert all(isinstance(auth, Auth.AppAuth) for auth in auths) def test_a_resolved_app_login_is_cached(monkeypatch): @@ -389,9 +392,11 @@ def test_a_resolved_app_login_is_cached(monkeypatch): monkeypatch.setattr(get_settings(), "github", SimpleNamespace( app_id="1", private_key="key", deployment_type="app"), raising=False) attempts = [] + auths = [] def integration(**kwargs): attempts.append(1) + auths.append(kwargs["auth"]) return SimpleNamespace(get_app=lambda: SimpleNamespace(slug="pr-agent")) monkeypatch.setattr("pr_agent.git_providers.github_provider.GithubIntegration", integration) @@ -399,6 +404,7 @@ def integration(**kwargs): assert provider._resolve_app_login() == "pr-agent[bot]" assert provider._resolve_app_login() == "pr-agent[bot]" assert len(attempts) == 1 + assert all(isinstance(auth, Auth.AppAuth) for auth in auths) def test_an_app_that_never_resolves_stays_unproven(monkeypatch): diff --git a/uv.lock b/uv.lock index 974b8374db..2458d94fd9 100644 --- a/uv.lock +++ b/uv.lock @@ -2637,7 +2637,7 @@ requires-dist = [ { name = "protobuf", specifier = "==6.33.6" }, { name = "pydantic", specifier = "==2.13.3" }, { name = "pydantic-settings", specifier = "==2.14.2" }, - { name = "pygithub", specifier = "==1.59.*" }, + { name = "pygithub", specifier = ">=2.10,<3" }, { name = "pyjwt", specifier = ">=2.13.0,<3" }, { name = "python-gitlab", specifier = "==8.3.0" }, { name = "pyyaml", specifier = "==6.0.1" }, @@ -2994,17 +2994,18 @@ wheels = [ [[package]] name = "pygithub" -version = "1.59.1" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecated" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pynacl" }, { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/30/203d3420960853e399de3b85d6613cea1cf17c1cf7fc9716f7ee7e17e0fc/PyGithub-1.59.1.tar.gz", hash = "sha256:c44e3a121c15bf9d3a5cc98d94c9a047a5132a9b01d22264627f58ade9ddc217", size = 3295328, upload-time = "2023-08-03T09:43:01.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/9b/195603d5371861005a3467c5e4afd02fd0698795a2aa36dc41498b9d879d/pygithub-2.10.0.tar.gz", hash = "sha256:90ff24ef1cd1bd57124c2a3869cafee9d7b066909129ecdaba2c2d1903bc118d", size = 2750952, upload-time = "2026-08-20T10:05:08.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/71/aff5465d9e3d448a5d4beab1dc7c8dec72037e3ae7e0d856ee08538dc934/PyGithub-1.59.1-py3-none-any.whl", hash = "sha256:3d87a822e6c868142f0c2c4bf16cce4696b5a7a4d142a7bd160e1bdf75bc54a9", size = 342171, upload-time = "2023-08-03T09:43:00.046Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/f314841697a1d52af3e1ea7c5e1c3f09685b64ae4c58ff16b605a866255d/pygithub-2.10.0-py3-none-any.whl", hash = "sha256:192ada2a76e4afc7d6b37e500c9bfeba1731e6506697445a5ba1c4af8bf0b924", size = 455554, upload-time = "2026-08-20T10:05:06.991Z" }, ] [[package]] From 5bf8b40e23fb77676f114aa058829fda1ed1536b Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:36:25 +0545 Subject: [PATCH 026/238] docs: add a complete configuration reference covering every option (#3217) Co-authored-by: Utsab --- .gitignore | 1 + docs/docs/summary.md | 1 + .../docs/usage-guide/configuration_options.md | 2 +- .../usage-guide/configuration_reference.md | 613 ++++++++++++++++++ docs/docs/usage-guide/index.md | 1 + docs/mkdocs.yml | 1 + pr_agent/settings/configuration.toml | 7 +- scripts/generate_config_reference.py | 232 +++++++ tests/unittest/test_config_reference.py | 43 ++ 9 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 docs/docs/usage-guide/configuration_reference.md create mode 100644 scripts/generate_config_reference.py create mode 100644 tests/unittest/test_config_reference.py diff --git a/.gitignore b/.gitignore index 040f445e1a..3169689682 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ build/ .DS_Store docs/.cache/ +docs/site/ .qodo poetry.lock .log diff --git a/docs/docs/summary.md b/docs/docs/summary.md index b84041b4c1..babd4565ad 100644 --- a/docs/docs/summary.md +++ b/docs/docs/summary.md @@ -20,6 +20,7 @@ * [Usage Guide](usage-guide/index.md) * [Introduction](usage-guide/introduction.md) * [Configuration File](usage-guide/configuration_options.md) +* [Configuration Reference](usage-guide/configuration_reference.md) * [Usage and Automation](usage-guide/automations_and_usage.md) * [Managing Mail Notifications](usage-guide/mail_notifications.md) * [Changing a Model](usage-guide/changing_a_model.md) diff --git a/docs/docs/usage-guide/configuration_options.md b/docs/docs/usage-guide/configuration_options.md index 7d0521b785..0f34d535a1 100644 --- a/docs/docs/usage-guide/configuration_options.md +++ b/docs/docs/usage-guide/configuration_options.md @@ -8,7 +8,7 @@ There are three main ways to set persistent configurations: In terms of precedence, local configurations will override global configurations, and global configurations will override an external configuration URL. -For a list of all possible configurations, see the [configuration options](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) page. +For a list of all possible configurations, see the [configuration options](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) page, or the rendered [Configuration Reference](./configuration_reference.md) which lists every option grouped by section. In addition to general configuration options, each tool has its own configurations. For example, the `review` tool will use parameters from the [pr_reviewer](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) section in the configuration file. !!! tip "Tip1: Edit only what you need" diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md new file mode 100644 index 0000000000..0f16c6ce6f --- /dev/null +++ b/docs/docs/usage-guide/configuration_reference.md @@ -0,0 +1,613 @@ +# Configuration Reference + +> This page is **auto-generated** and should not be edited by hand. +> Regenerate it from the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) with: +> +> ```bash +> python scripts/generate_config_reference.py +> ``` + +Every configuration option PR-Agent supports, grouped by section. The [configuration.toml](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) +file is the single source of truth for defaults and inline comments; this page renders the same +list for easy searching and linking. + +Rows with an empty **Description** are keys whose TOML entry carries no explanatory comment yet. +They are listed deliberately rather than hidden, so the gaps double as the documentation +to-do list. + +## `[config]` + +**models** + +| Key | Default | Description | +| --- | --- | --- | +| `model` | "gpt-5.6" | | +| `fallback_models` | ["gpt-5.6-terra"] | | +**CLI** + +| Key | Default | Description | +| --- | --- | --- | +| `git_provider` | "github" | | +| `publish_output` | true | | +| `publish_output_progress` | true | | +| `progress_gif_url` | "" | optional, override for the progress loading gif url (example: 'https://.../spinner.gif'). | +| `progress_gif_width` | 48 | optional, width (in px) of the progress loading gif. | +| `verbosity_level` | 0 | 0,1,2 | +| `use_extra_bad_extensions` | false | | +**Log** + +| Key | Default | Description | +| --- | --- | --- | +| `log_level` | "DEBUG" | | +**Configurations** + +| Key | Default | Description | +| --- | --- | --- | +| `use_repo_settings_file` | true | | +| `use_global_settings_file` | true | | +| `extra_config_url` | "" | optional URL or path to an additional .pr_agent.toml merged before the repo-local config; also settable via --extra_config_url or PR_AGENT_EXTRA_CONFIG_URL. See docs/docs/usage-guide/configuration_options.md#external-configuration-url. | +| `disable_auto_feedback` | false | | +| `enable_auto_approval` | false | when true, /review may auto-approve a PR via auto_approve_logic(); that caller is currently commented out | +| `ai_timeout` | 120 | 2 minutes | +| `retry_same_model_on_timeout` | true | when false, a timed-out call is not retried on the same model and moves on to fallback_models | +| `skip_keys` | [] | | +| `custom_reasoning_model` | false | when true, disables system messages and temperature controls for models that don't support chat-style inputs | +| `response_language` | "en-US" | Language locales code for PR responses in ISO 3166 and ISO 639 format (e.g., "en-US", "it-IT", "zh-CN", ...) | +| `repo_context_files` | ["AGENTS.md"] | Repository-relative files (e.g. AGENTS.md, CLAUDE.md) to include as AI prompt context; set to [] to disable | +| `repo_context_from_default_branch` | true | Read repo context files from the repository default branch (trusts only default-branch content). Set to false to read from the PR target branch instead. | +| `repo_context_max_lines` | 500 | Maximum total rendered lines for repo context, including wrapper tags | +**token limits** + +| Key | Default | Description | +| --- | --- | --- | +| `max_description_tokens` | 500 | | +| `max_commits_tokens` | 500 | | +| `max_model_tokens` | 32000 | Limits the maximum number of tokens that can be used by any model, regardless of the model's default capabilities. | +| `custom_model_max_tokens` | -1 | for models not in the default list | +| `max_output_tokens` | 0 | 0 = unset (the provider's own default applies) | +| `model_token_count_estimate_factor` | 0.3 | factor to increase the token count estimate, in order to reduce likelihood of model failure due to too many tokens - applicable only when requesting an accurate estimate. | +**patch extension logic** + +| Key | Default | Description | +| --- | --- | --- | +| `patch_extension_skip_types` | [".md", ".txt"] | | +| `allow_dynamic_context` | true | | +| `max_extra_lines_before_dynamic_context` | 10 | will try to include up to 10 extra lines before the hunk in the patch, until we reach an enclosing function or class | +| `patch_extra_lines_before` | 5 | Number of extra lines (+3 default ones) to include before each hunk in the patch | +| `patch_extra_lines_after` | 1 | Number of extra lines (+3 default ones) to include after each hunk in the patch | +| `secret_provider` | "" | "" (disabled), "google_cloud_storage", or "aws_secrets_manager" for secure secret management | +| `cli_mode` | false | | +| `output_relevant_configurations` | false | | +| `output_run_details` | false | if true, append an agent run details section (model, tokens, time cost, AI calls) to generated PR comments | +| `output_run_cost` | false | if true, collect estimated LiteLLM API cost and include it inside the enabled run details section | +| `large_patch_policy` | "clip" | "clip", "skip" | +| `duplicate_prompt_examples` | false | | +| `persistent_inline_comments` | false | Persistent inline comments (issue #2037): when true, the GitHub, GitLab, and Azure DevOps providers fingerprint each inline comment, embed the fingerprint as an HTML marker, and skip re-posting suggestions already present on the PR/MR across runs. | +**seed** + +| Key | Default | Description | +| --- | --- | --- | +| `seed` | -1 | set positive value to fix the seed (and ensure temperature=0) | +| `temperature` | 0.2 | | +**ignore logic** + +| Key | Default | Description | +| --- | --- | --- | +| `ignore_pr_title` | ["^\\[Auto\\]", "^Auto"] | a list of regular expressions to match against the PR title to ignore the PR agent | +| `ignore_pr_target_branches` | [] | a list of regular expressions of target branches to ignore from PR agent when an PR is created | +| `ignore_pr_source_branches` | [] | a list of regular expressions of source branches to ignore from PR agent when an PR is created | +| `ignore_pr_labels` | [] | labels to ignore from PR agent when an PR is created | +| `ignore_pr_authors` | [] | authors to ignore from PR agent when an PR is created | +| `reaction_on_start` | "eyes" | added before the command runs | +| `reaction_on_success` | "" | added when the command finished successfully | +| `reaction_on_failure` | "" | added when the command failed | +| `ignore_repositories` | [] | a list of regular expressions of repository full names (e.g. "org/repo") to ignore from PR agent processing | +| `ignore_language_framework` | [] | a list of code-generation languages or frameworks (e.g. 'protobuf', 'go_gen') whose auto-generated source files will be excluded from analysis | +| `bot_user_indicators` | ["codium", "bot_", "bot-", "_bot", "-bot"] | Substring indicators used to skip bot users on webhook events. Currently consumed by the GitLab webhook (`is_bot_user`); other providers may adopt this list in future. The match is case-insensitive against the sender's display name. Overriding this setting REPLACES the default list — include the entries below in your override if you want to keep them (e.g. `["codium", "bot_", "bot-", "_bot", "-bot", "renovate"]`). | +| `restricted_mode` | false | when true, skip operations that require elevated permissions (e.g. pushing code to the repository) | +| `is_auto_command` | false | will be auto-set to true if the command is triggered by an automation | +| `propagate_tool_errors` | false | when true, a tool re-raises instead of swallowing an internal error, so a caller can tell a failed run from an empty one | +| `enable_ai_metadata` | false | will enable adding ai metadata | +| `add_user_to_requests` | false | send the current command and PR URL in the OpenAI-compatible "user" request field, for provider-side attribution of requests (e.g. OpenRouter "external_user") | +| `reasoning_effort` | "medium" | "none", "minimal", "low", "medium", "high", "xhigh", "max" | +**extended thinking for Claude reasoning models** + +| Key | Default | Description | +| --- | --- | --- | +| `enable_claude_extended_thinking` | false | Set to true to enable extended thinking feature | +| `extended_thinking_budget_tokens` | 2048 | | +| `extended_thinking_max_output_tokens` | 4096 | | +| `enable_claude_adaptive_thinking` | false | Adaptive thinking for Claude Opus 4.7/4.8 and Claude 5 models. When enabled, these models receive thinking={"type": "adaptive"} and an output_config effort. Do not add adaptive-only models to claude_extended_thinking_models_override; when both features are enabled, adaptive thinking takes precedence. | +| `claude_extended_thinking_models_override` | [] | Optional: override the built-in list of Claude models that receive the extended-thinking payload. When non-empty, this list fully replaces the built-in defaults (see CLAUDE_EXTENDED_THINKING_MODELS in pr_agent/algo/__init__.py). Leave empty to use the defaults. | +| `extract_issue_from_branch` | true | Extract issue number from PR source branch name (e.g. feature/1-auth-google -> issue #1). When true, branch-derived issue URLs are merged with tickets from the PR description for compliance. Set to false to restore description-only behaviour. Note: Branch-name extraction is GitHub-only for now; other providers planned for later. | +| `branch_issue_regex` | "" | Optional: custom regex with exactly one capturing group for the issue number (validated at runtime; falls back to default if missing). If empty, uses default pattern: first 1-6 digits at start of branch or after a slash, followed by hyphen or end (e.g. feature/1-test, 123-fix). GitHub only; other providers planned for later. | + + +## `[pr_reviewer]` — /review + +**enable/disable features** + +| Key | Default | Description | +| --- | --- | --- | +| `require_score_review` | false | | +| `require_tests_review` | true | | +| `require_estimate_effort_to_review` | true | | +| `require_can_be_split_review` | false | | +| `require_security_review` | true | | +| `require_estimate_contribution_time_cost` | false | | +| `require_todo_scan` | false | | +| `require_ticket_analysis_review` | true | | +| `require_risk_assessment` | false | ask the model for an overall risk level (low/medium/high) | +| `require_merge_recommendation` | false | ask the model for a merge recommendation (safe_to_merge/merge_with_caution/changes_required) | +| `require_priority_files` | false | ask the model which files a human should inspect first | +**general options** + +| Key | Default | Description | +| --- | --- | --- | +| `publish_output_no_suggestions` | true | Set to "false" if you only need the reviewer's remarks (not labels, not "security audit", etc.) and want to avoid noisy "No major issues detected" comments. | +| `publish_error_details` | false | Publish a deterministic, sanitized failure reason in manual review comments. No AI call is used. | +| `persistent_comment` | true | | +| `review_heading` | "PR Reviewer Guide" | Visible base heading for full and incremental review comments. Identity is tracked separately. | +| `persistent_finding_state` | true | Persist review finding state across complete review runs. | +| `inline_key_issues` | false | | +| `extra_instructions` | "" | | +| `num_max_findings` | 3 | | +| `final_update_message` | true | | +**review labels** + +| Key | Default | Description | +| --- | --- | --- | +| `enable_review_labels_security` | true | | +| `enable_review_labels_effort` | true | | +**specific configurations for incremental review (/review -i)** + +| Key | Default | Description | +| --- | --- | --- | +| `require_all_thresholds_for_incremental_review` | false | | +| `minimal_commits_for_incremental_review` | 0 | | +| `minimal_minutes_for_incremental_review` | 0 | | +| `enable_intro_text` | true | | +| `enable_help_text` | false | Determines whether to include help text in the PR review. Enabled by default. | +| `enable_review_coverage_footer` | true | | +| `enable_large_pr_chunking` | false | large-diff chunking (opt-in). When the token budget leaves files out of the review, split the diff into chunks, review each chunk, and merge the per-chunk results into one review. | +| `max_number_of_calls` | 3 | maximum number of chunk review calls, used only when enable_large_pr_chunking is true | + + +## `[pr_description]` — /describe + +| Key | Default | Description | +| --- | --- | --- | +| `publish_labels` | false | | +| `add_original_user_description` | true | | +| `generate_ai_title` | false | | +| `use_bullet_points` | true | | +| `extra_instructions` | "" | | +| `enable_pr_type` | true | | +| `enable_pr_description` | true | adds a section with an AI-generated summary of the PR changes | +| `final_update_message` | true | | +| `enable_help_text` | false | | +| `enable_help_comment` | false | | +| `enable_pr_diagram` | true | adds a section with a diagram of the PR changes | +| `pr_diagram_direction` | "adaptive" | 'adaptive', 'LR', 'TD'. 'adaptive' picks the direction from the shape of the diagram | +| `pr_diagram_direction_threshold` | 5 | with 'adaptive', a chain longer than this many nodes is drawn top-down | +**describe as comment** + +| Key | Default | Description | +| --- | --- | --- | +| `publish_description_as_comment` | false | | +| `publish_description_as_comment_persistent` | true | | +**changes walkthrough section** + +| Key | Default | Description | +| --- | --- | --- | +| `enable_semantic_files_types` | true | | +| `collapsible_file_list` | "adaptive" | true, false, 'adaptive' | +| `collapsible_file_list_threshold` | 6 | | +| `file_table_collapsible_open_by_default` | false | | +| `inline_file_summary` | false | false, true, 'table' | +**markers** + +| Key | Default | Description | +| --- | --- | --- | +| `use_description_markers` | false | | +| `enable_large_pr_handling` | true | | +| `include_generated_by_header` | true | | +| `max_ai_calls` | 4 | | +| `async_ai_calls` | true | | + + +## `[pr_questions]` — /ask + +| Key | Default | Description | +| --- | --- | --- | +| `enable_help_text` | false | | +| `use_conversation_history` | true | | +| `ask_heading` | "Ask" | Set the plain-text heading for top-level /ask answers; provider-specific presentation is added automatically. | +| `resolve_threads` | false | Enable to let /ask_line resolve the review thread when the LLM judges the issue addressed. Note: also resolves threads started by human reviewers. | +| `extra_instructions` | "" | | + + +## `[pr_code_suggestions]` — /improve + +| Key | Default | Description | +| --- | --- | --- | +| `commitable_code_suggestions` | false | | +| `dual_publishing_score_threshold` | -1 | -1 to disable, [0-10] to set the threshold (>=) for publishing a code suggestion both in a table and as committable | +| `focus_only_on_problems` | true | | +| `extra_instructions` | "" | | +| `suggestions_heading` | "PR Code Suggestions" | Visible base heading for summary-table /improve comments. Identity is tracked separately. | +| `enable_help_text` | false | | +| `enable_chat_text` | false | | +| `persistent_comment` | true | | +| `max_history_len` | 4 | | +| `publish_output_no_suggestions` | true | | +| `enable_suggestions_coverage_footer` | true | show when failed analysis chunks make the suggestions incomplete | +**suggestions scoring** + +| Key | Default | Description | +| --- | --- | --- | +| `suggestions_score_threshold` | 0 | [0-10]\| recommend not to set this value above 8, since above it may clip highly relevant suggestions | +| `new_score_mechanism` | true | | +| `new_score_mechanism_th_high` | 9 | | +| `new_score_mechanism_th_medium` | 7 | | +**params for '/improve --extended' mode** + +| Key | Default | Description | +| --- | --- | --- | +| `auto_extended_mode` | true | | +| `num_code_suggestions_per_chunk` | 3 | | +| `max_suggestions_per_file` | 0 | Maximum suggestions retained per file after all chunks are merged; 0 disables the cap. | +| `max_number_of_calls` | 3 | | +| `parallel_calls` | true | | +| `final_clip_factor` | 0.8 | | +| `decouple_hunks` | false | | +**self-review checkbox** + +| Key | Default | Description | +| --- | --- | --- | +| `demand_code_suggestions_self_review` | false | add a checkbox for the author to self-review the code suggestions | +| `code_suggestions_self_review_text` | "**Author self-review**: I have reviewed the PR code suggestions, and addressed the relevant ones." | | +| `approve_pr_on_self_review` | false | if true, the PR will be auto-approved after the author clicks on the self-review checkbox | +| `fold_suggestions_on_self_review` | true | if true, the code suggestions will be folded after the author clicks on the self-review checkbox | + + +## `[pr_custom_prompt]` — /custom_prompt + +| Key | Default | Description | +| --- | --- | --- | +| `prompt` | """The code suggestions should focus only on the following: ...""" | | +| `suggestions_score_threshold` | 0 | | +| `num_code_suggestions_per_chunk` | 3 | | +| `self_reflect_on_custom_suggestions` | true | | +| `enable_help_text` | false | | + + +## `[pr_add_docs]` — /add_docs + +| Key | Default | Description | +| --- | --- | --- | +| `extra_instructions` | "" | | +| `docs_style` | "Sphinx" | "Google Style with Args, Returns, Attributes...etc", "Numpy Style", "Sphinx Style", "PEP257", "reStructuredText" | +| `file` | "" | in case there are several components with the same name, you can specify the relevant file | +| `class_name` | "" | in case there are several methods with the same name in the same file, you can specify the relevant class name | + + +## `[pr_update_changelog]` — /update_changelog + +| Key | Default | Description | +| --- | --- | --- | +| `push_changelog_changes` | false | | +| `extra_instructions` | "" | | +| `add_pr_link` | true | | +| `skip_ci_on_push` | true | | + + +## `[pr_analyze]` — /analyze + +| Key | Default | Description | +| --- | --- | --- | +| `enable_help_text` | true | | + + +## `[pr_test]` — /test + +| Key | Default | Description | +| --- | --- | --- | +| `extra_instructions` | "" | | +| `testing_framework` | "" | specify the testing framework you want to use | +| `num_tests` | 3 | number of tests to generate. max 5. | +| `avoid_mocks` | true | if true, the generated tests will prefer to use real objects instead of mocks | +| `file` | "" | in case there are several components with the same name, you can specify the relevant file | +| `class_name` | "" | in case there are several methods with the same name in the same file, you can specify the relevant class name | +| `enable_help_text` | false | | + + +## `[pr_improve_component]` — /improve_component + +| Key | Default | Description | +| --- | --- | --- | +| `num_code_suggestions` | 4 | | +| `extra_instructions` | "" | | +| `file` | "" | in case there are several components with the same name, you can specify the relevant file | +| `class_name` | "" | in case there are several methods with the same name in the same file, you can specify the relevant class name | + + +## `[pr_help]` — /help + +| Key | Default | Description | +| --- | --- | --- | +| `force_local_db` | false | | +| `num_retrieved_snippets` | 5 | | + + +## `[pr_config]` — /config + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[pr_help_docs]` + +| Key | Default | Description | +| --- | --- | --- | +| `repo_url` | "" | If not overwritten, will use the repo from where the context came from (issue or PR) | +| `repo_default_branch` | "main" | | +| `docs_path` | "docs" | | +| `exclude_root_readme` | false | | +| `supported_doc_exts` | [".md", ".mdx", ".rst"] | | +| `enable_help_text` | false | | + + +## `[github]` + +| Key | Default | Description | +| --- | --- | --- | +| `deployment_type` | "user" | The type of deployment to create. Valid values are 'app' or 'user'. | +| `ratelimit_retries` | 5 | | +| `polling_request_timeout` | 10 | total seconds for comment-history fallback; positive values capped at 60 | +| `base_url` | "https://api.github.com" | | +| `publish_inline_comments_fallback_with_verification` | true | | +| `try_fix_invalid_inline_comments` | true | | +| `app_name` | "pr-agent" | | +| `ignore_bot_pr` | true | | +| `publish_as_check_run` | false | when true, publish review/description/improve output as GitHub Checks instead of PR comments | + + +## `[github_action_config]` + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[github_app]` + +| Key | Default | Description | +| --- | --- | --- | +| `bot_user` | "github-actions[bot]" | these toggles allows running the github app from custom deployments | +| `override_deployment_type` | true | | +**settings for "pull_request" event** + +| Key | Default | Description | +| --- | --- | --- | +| `handle_pr_actions` | ["opened", "reopened", "ready_for_review"] | | +| `feedback_on_draft_pr` | false | | +| `review_states` | ["changes_requested"] | A submitted GitHub review can optionally trigger these commands. The empty default preserves current behavior. | +| `review_author_types` | ["User"] | | +| `review_commands` | [] | | +| `handle_push_trigger` | false | settings for "pull_request" event with "synchronize" action - used to detect and handle push triggers for new commits | +| `push_trigger_ignore_bot_commits` | true | | +| `push_trigger_ignore_merge_commits` | true | | +| `push_trigger_wait_for_initial_review` | true | | +| `push_trigger_pending_tasks_backlog` | true | | +| `push_trigger_pending_tasks_ttl` | 300 | | +| `push_commands` | ["/describe", "/review"] | | + + +## `[gitlab]` + +| Key | Default | Description | +| --- | --- | --- | +| `url` | "https://gitlab.com" | | +| `expand_submodule_diffs` | false | | +| `feedback_on_draft_pr` | false | | +| `publish_review_as_thread` | false | Post the /review summary as a resolvable thread (discussion) instead of a plain note. | +| `publish_improve_as_thread` | false | Post the /improve suggestions comment as a resolvable thread (discussion) instead of a plain note. | +| `publish_code_suggestions_as_review` | false | When pr_code_suggestions.commitable_code_suggestions is true, queue each suggestion as a GitLab draft note and publish them all together in one batch (like GitLab's own "start a review" flow) instead of posting each as its own live discussion - and its own notification - as soon as it's created. | +| `resolve_outdated_inline_threads` | false | Resolve the bot's own inline threads that a later push left on an outdated diff version. | +| `handle_push_trigger` | false | | +| `push_commands` | ["/describe", "/review"] | | +| `handle_reviewer_assignment` | false | Auto-trigger commands when the bot is assigned as a reviewer on an MR | +| `reviewer_commands` | ["/review"] | | + + +## `[gitea]` + +| Key | Default | Description | +| --- | --- | --- | +| `url` | "https://gitea.com" | | +| `handle_push_trigger` | false | | +| `push_commands` | ["/describe", "/review"] | | + + +## `[bitbucket_app]` + +| Key | Default | Description | +| --- | --- | --- | +| `avoid_full_files` | false | | +| `request_timeout` | 30 | positive seconds for connection and response-read inactivity timeouts | + + +## `[local]` + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[gerrit]` + +| Key | Default | Description | +| --- | --- | --- | +| `webhook_username` | "" | both are required: the gerrit webhook endpoint rejects every call until they are set | +| `webhook_password` | "" | | + + +## `[bitbucket_server]` + +| Key | Default | Description | +| --- | --- | --- | +| `url` | "" | URL to the BitBucket Server instance | + + +## `[litellm]` + +| Key | Default | Description | +| --- | --- | --- | +| `enable_callbacks` | false | | +| `success_callback` | [] | | +| `failure_callback` | [] | | +| `service_callback` | [] | | +| `turn_off_message_logging` | false | set true to keep prompt/response content (the whole PR diff) out of callback payloads; strongly recommended with the "otel" callback | +| `custom_llm_provider` | "" | Optional: forward a fixed custom_llm_provider to LiteLLM, so a raw hosted model id (e.g. "claude-sonnet-4-5") reaches the provider unchanged instead of being rewritten by LiteLLM's prefix inference. Empty = let LiteLLM infer the provider from the model name. | +| `force_streaming_custom_llm_provider` | "" | Force streaming when the request matches this provider AND its api_base contains one of the substrings below. Some OpenAI-compatible endpoints return a response that LiteLLM cannot normalize in non-streaming mode. Both must be set for the workaround to apply. | +| `force_streaming_api_base_substrings` | [] | | +| `callback_timeout_seconds` | 30 | max seconds to wait for pending litellm callbacks to flush before exiting | +| `cache_control_injection_points` | [] | Optional: enable Anthropic prompt caching via LiteLLM, e.g. [{location = "message", role = "system"}] (https://docs.litellm.ai/docs/tutorials/prompt_caching) | + + +## `[openrouter]` + +| Key | Default | Description | +| --- | --- | --- | +| `provider_only` | [] | restrict routing to these upstream providers only, a hard allowlist (e.g. ["z-ai"]); empty = OpenRouter default routing | +| `provider_order` | [] | preferred provider order; ignored when provider_only is set; empty = unset | +| `allow_fallbacks` | true | when provider_order is set, allow routing beyond the listed providers | +| `reasoning_effort` | "" | Invalid reasoning_effort values are warned about and treated as unset. Empty inherits config.reasoning_effort for models in SUPPORT_REASONING_EFFORT_MODELS. Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". OpenRouter normalizes "max" to "xhigh" to match LiteLLM 1.98.0. Model-specific support varies; mandatory reasoning models reject "none". | +| `reasoning_max_tokens` | 0 | A positive value overrides global effort and non-none OpenRouter-specific efforts. Explicit openrouter.reasoning_effort = "none" keeps reasoning disabled, except on Grok 4.5/4.6: there "none" is clamped to the lowest supported effort first, so a positive budget wins over it. Some providers require max_tokens to be greater than the reasoning budget. | +| `max_tokens` | 0 | hard cap on completion tokens for the request; 0 = unset | + + +## `[model_routing]` — send a small pull request to a cheaper primary model (disabled by default) + +| Key | Default | Description | +| --- | --- | --- | +| `enable` | false | Rules are checked in order; the first one whose limits the pull request fits selects the primary model for the call, and config.fallback_models still apply after it. A pull request that fits no rule uses config.model. Size is measured after the [ignore] rules by diff hunks (max_hunks) and changed files (max_files), which every git provider reports and which do not depend on any model's tokenizer. Only calls that ask for the regular model are routed (/review, /improve, /generate_labels, /add_docs); tools that use model_weak are left alone. With Azure (openai.deployment_id set) a rule also needs its own deployment_id, or it is skipped. | +| `rules` | [] | e.g. [{ max_hunks = 3, model = "gpt-5.6-luna" }, { max_hunks = 15, max_files = 6, model = "gpt-5.6-terra" }] | + + +## `[otel]` + +| Key | Default | Description | +| --- | --- | --- | +| `is_enabled` | false | disabled by default; set to true to enable telemetry | +| `exporter_type` | "console" | "console", "otlp", or "none" | +| `service_name` | "pr-agent" | | +| `environment` | "development" | "development", "staging", "production", etc. | +| `otlp_timeout` | 3 | seconds; hard deadline per OTLP export call (incl. retries). Bounds CLI-exit/request stalls when the collector is unreachable; batches slower than this are dropped. | +| `otlp_protocol` | "http" | "http" (default; exporter ships with pr-agent) or "grpc" (requires the otel-grpc extra: pip install pr-agent[otel-grpc]) | +| `include_pr_url` | false | set to true to attach PR URLs to spans (may expose private repo names) | +| `include_error_details` | false | set to true to attach exception messages and rejected-command text to spans (may expose PR URLs, repo names, or other request content) | + + +## `[pr_similar_issue]` + +| Key | Default | Description | +| --- | --- | --- | +| `skip_comments` | false | | +| `force_update_dataset` | false | | +| `max_issues_to_scan` | 500 | | +| `vectordb` | "pinecone" | options: "pinecone", "lancedb", "qdrant" | + + +## `[pr_find_similar_component]` + +| Key | Default | Description | +| --- | --- | --- | +| `class_name` | "" | | +| `file` | "" | | +| `search_from_org` | false | | +| `allow_fallback_less_words` | true | | +| `number_of_keywords` | 5 | | +| `number_of_results` | 5 | | + + +## `[pinecone]` + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[lancedb]` + +| Key | Default | Description | +| --- | --- | --- | +| `uri` | "./lancedb" | | + + +## `[qdrant]` + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[skills]` + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | false | Agent skills (SKILL.md) support: discovers SKILL.md files from the configured filesystem paths and injects their content into review/improve/describe and top-level /ask prompts. Sibling *.md files in the skill directory tree (e.g. references/guide.md) are inlined alongside SKILL.md. PR-Agent supports text-only skills: scripts/ and assets/ subdirectories are skipped because PR-Agent uses a single-shot model call (no tool-use loop) and cannot execute scripts or load binary assets on demand. Skills that depend on script execution will not work here. See https://github.com/The-PR-Agent/pr-agent/issues/2384 | +| `paths` | [] | directories to scan recursively for "*/SKILL.md"; supports ~ and $VAR | +| `max_skills_tokens` | 8000 | token budget for the combined skills_context block | + + +## `[best_practices]` + +| Key | Default | Description | +| --- | --- | --- | +| `content` | "" | | +| `organization_name` | "" | | +| `max_lines_allowed` | 800 | | +| `enable_global_best_practices` | false | | + + +## `[auto_best_practices]` + +| Key | Default | Description | +| --- | --- | --- | +| `enable_auto_best_practices` | true | public - general flag to disable all auto best practices usage | +| `utilize_auto_best_practices` | true | public - disable usage of auto best practices in the 'improve' tool | +| `extra_instructions` | "" | public - extra instructions to the auto best practices generation prompt | +| `content` | "" | | +| `max_patterns` | 5 | max number of patterns to be detected | + + +## `[artifacts]` + +| Key | Default | Description | +| --- | --- | --- | +| `enable` | false | Enable artifact injection into tool prompts (off by default; auto-enabled when artifact_path input is set) | +| `artifact_path` | "" | File path to the artifact (relative to GITHUB_WORKSPACE, or absolute) | +| `artifact_instructions` | "" | Custom instructions appended after the artifact content (leave empty for a sensible default) | +| `artifact_label` | "" | Label shown to the AI — defaults to the filename when empty. | +| `target_tools` | ["pr_reviewer", "pr_description", "pr_code_suggestions"] | Which tools receive artifact context. | +| `max_artifact_size` | 50000 | Max artifact size in characters (content is truncated if exceeded) | + + +## `[asana]` + +| Key | Default | Description | +| --- | --- | --- | +| `request_timeout` | 10 | seconds allowed for each Asana task API request | + + +## `[azure_devops]` + +| Key | Default | Description | +| --- | --- | --- | +| `default_comment_status` | "closed" | | + + +## `[azure_devops_server]` + +| Key | Default | Description | +| --- | --- | --- | +| `agent_identity` | "" | empty: discover the identity from earlier agent comments on the PR | + + +## `[push_outputs]` — push tool outputs to external sinks without calling git-provider APIs (disabled by default) + +| Key | Default | Description | +| --- | --- | --- | +| `enable` | false | | +| `channels` | [] | any of: "stdout", "file", "webhook", "slack". Nothing is emitted until a channel is listed here | +| `file_path` | "pr-agent-outputs/reviews.jsonl" | used by the "file" channel | +| `webhook_url` | "" | used by the "webhook" channel: generic JSON POST target. Must be an absolute https:// URL | +| `slack_webhook_url` | "" | used by the "slack" channel: a Slack Incoming Webhook URL. Must be an absolute https:// URL | diff --git a/docs/docs/usage-guide/index.md b/docs/docs/usage-guide/index.md index bb08ee6f1c..af65146f83 100644 --- a/docs/docs/usage-guide/index.md +++ b/docs/docs/usage-guide/index.md @@ -5,6 +5,7 @@ It includes information on how to adjust PR-Agent configurations, define which t - [Introduction](./introduction.md) - [Configuration File](./configuration_options.md) +- [Configuration Reference](./configuration_reference.md) - [Usage and Automation](./automations_and_usage.md) - [Local Repo (CLI)](./automations_and_usage.md#local-repo-cli) - [Online Usage](./automations_and_usage.md#online-usage) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 536eb5c453..ff87557569 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -20,6 +20,7 @@ nav: - 'usage-guide/index.md' - Introduction: 'usage-guide/introduction.md' - Configuration File: 'usage-guide/configuration_options.md' + - Configuration Reference: 'usage-guide/configuration_reference.md' - Usage and Automation: 'usage-guide/automations_and_usage.md' - Managing Mail Notifications: 'usage-guide/mail_notifications.md' - Changing a Model: 'usage-guide/changing_a_model.md' diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 74ffd34b2d..6d310fa225 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -406,9 +406,8 @@ webhook_password = "" # patch_server_token = "" [bitbucket_server] -# URL to the BitBucket Server instance # url = "https://git.bitbucket.com" -url = "" +url = "" # URL to the BitBucket Server instance [litellm] # use_client = false @@ -538,9 +537,9 @@ enable = false artifact_path = "" # Custom instructions appended after the artifact content (leave empty for a sensible default) artifact_instructions = "" -# Label shown to the AI — defaults to the filename when empty +# Label shown to the AI — defaults to the filename when empty. artifact_label = "" -# Which tools receive artifact context +# Which tools receive artifact context. target_tools = ["pr_reviewer", "pr_description", "pr_code_suggestions"] # Max artifact size in characters (content is truncated if exceeded) max_artifact_size = 50000 diff --git a/scripts/generate_config_reference.py b/scripts/generate_config_reference.py new file mode 100644 index 0000000000..f66229b52e --- /dev/null +++ b/scripts/generate_config_reference.py @@ -0,0 +1,232 @@ +"""Generate docs/docs/usage-guide/configuration_reference.md from configuration.toml. + +The generated page lists every active option in pr_agent/settings/configuration.toml, +grouped by section, rendering each default value and its inline comment so the docs +stay in sync with the authoritative source. Run from the repo root: + + python scripts/generate_config_reference.py [output_path] + +The output path defaults to docs/docs/usage-guide/configuration_reference.md. +""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONFIG_TOML = ROOT / "pr_agent/settings/configuration.toml" +DEFAULT_OUTPUT = ROOT / "docs/docs/usage-guide/configuration_reference.md" + +SOURCE_URL = "https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml" + +PAGE_HEADER = f"""# Configuration Reference + +> This page is **auto-generated** and should not be edited by hand. +> Regenerate it from the [TOML source]({SOURCE_URL}) with: +> +> ```bash +> python scripts/generate_config_reference.py +> ``` + +Every configuration option PR-Agent supports, grouped by section. The [configuration.toml]({SOURCE_URL}) +file is the single source of truth for defaults and inline comments; this page renders the same +list for easy searching and linking. + +Rows with an empty **Description** are keys whose TOML entry carries no explanatory comment yet. +They are listed deliberately rather than hidden, so the gaps double as the documentation +to-do list. +""" + +TABLE_HEADER = "| Key | Default | Description |\n| --- | --- | --- |" + +# Comment lines shorter than this without trailing sentence punctuation are treated as +# group labels (e.g. "# models") rather than descriptions of the following key. +LABEL_MAX_LEN = 60 + +KEY_RE = re.compile(r"^(?P[a-zA-Z0-9_]+)\s*=\s*(?P.*)$") +SECTION_RE = re.compile(r"^\[(?P[^\]]+)\](?:\s*#+\s*(?P.+?)\s*#*)?$") + + +def _is_assignment(text: str) -> bool: + return bool(re.match(r"^[a-zA-Z0-9_\"']+\s*=", text)) + + +def _classify_comment(text: str) -> str: + stripped = text.strip().lstrip("#").strip() + if not stripped: + return "empty" + if _is_assignment(stripped): + return "example" + if len(stripped) <= LABEL_MAX_LEN and not re.search(r"[.:!?]", stripped): + return "label" + return "desc" + + +def _probe_value(buf: str) -> tuple: + """Return (parsed_value, value_text, inline_comment) or (None, None, None).""" + idx = buf.find("#") + if idx != -1 and buf[:idx].rstrip(): + candidate = buf[:idx].rstrip() + try: + return tomllib.loads(f"k = {candidate}")["k"], candidate, buf[idx + 1 :].strip() + except tomllib.TOMLDecodeError: + pass + try: + return tomllib.loads(f"k = {buf}")["k"], buf, None + except tomllib.TOMLDecodeError: + return None, None, None + + +def _render_value(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + if "\n" in value: + lines = [line.strip() for line in value.strip().splitlines() if line.strip()] + first = lines[0] if lines else "" + text = first + (" ..." if len(lines) > 1 else "") + return '"""' + text + '"""' + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + if isinstance(value, list): + return "[" + ", ".join(_render_value(item) for item in value) + "]" + if isinstance(value, dict): + items = ", ".join(f"{_render_value(key)} = {_render_value(item)}" for key, item in value.items()) + return "{" + items + "}" + return str(value) + + +def _escape_cell(text: str) -> str: + return text.replace("|", "\\|").replace("\n", " ") + + +def load_sections(text: str) -> list: + sections = [] + current = None + pending_comments = [] + + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i].strip() + + section_match = SECTION_RE.match(line) + if section_match: + current = {"name": section_match.group("name"), "tag": (section_match.group("tag") or "").strip()} + current["keys"] = [] + sections.append(current) + pending_comments = [] + i += 1 + continue + + if not line: + i += 1 + continue + + if line.startswith("#"): + if current is not None: + kind = _classify_comment(line) + if kind in ("example", "empty"): + pending_comments = [] + else: + pending_comments.append(line.lstrip("#").strip()) + i += 1 + continue + + key_match = KEY_RE.match(line) + if key_match is None or current is None: + i += 1 + continue + + buf = key_match.group("rest") + value, value_text, inline_comment = _probe_value(buf) + i += 1 + while value is None and i < len(lines): + buf += "\n" + lines[i] + value, value_text, inline_comment = _probe_value(buf) + i += 1 + if value is None: + value_text, inline_comment = buf, None + + desc = inline_comment + if desc is None: + desc_parts = [c for c in pending_comments if _classify_comment(c) == "desc"] + desc = " ".join(desc_parts) or None + label_parts = [c for c in pending_comments if _classify_comment(c) == "label"] + label = label_parts[-1] if label_parts else None + pending_comments = [] + + current["keys"].append( + { + "key": key_match.group("key"), + "value": _render_value(value) if value is not None else value_text, + "desc": desc, + "label": label, + } + ) + + return sections + + +def render_page(sections: list) -> str: + out = [PAGE_HEADER.rstrip(), ""] + + for section in sections: + keys = section["keys"] + heading = f"## `[{section['name']}]`" + if section["tag"]: + heading += f" — {section['tag']}" + out.append(heading) + out.append("") + if not keys: + out.append("_This section only documents commented-out examples; see the [TOML source]" + f"({SOURCE_URL}) for details._") + out.append("") + continue + + table_open = False + pending_label = None + for key in keys: + if key["label"]: + if table_open: + table_open = False + pending_label = "**" + key["label"] + "**" + if not table_open: + if pending_label: + out.append(pending_label) + out.append("") + pending_label = None + out.append(TABLE_HEADER) + table_open = True + desc = _escape_cell(key["desc"]) if key["desc"] else "" + value = _escape_cell(key["value"]) + out.append(f"| `{key['key']}` | {value if value.strip() else '`""`'} | {desc} |") + if table_open: + out.append("") + out.append("") + + return "\n".join(out).rstrip("\n") + "\n" + + +def main() -> int: + output = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUTPUT + text = CONFIG_TOML.read_text(encoding="utf-8") + sections = load_sections(text) + + rendered_keys = sum(len(section["keys"]) for section in sections) + expected_keys = len(re.findall(r"^[a-zA-Z0-9_]+\s*=", text, re.M)) + if rendered_keys != expected_keys: + print(f"ERROR: rendered {rendered_keys} keys, expected {expected_keys}", file=sys.stderr) + return 1 + + output.write_text(render_page(sections), encoding="utf-8") + print(f"Wrote {rendered_keys} keys across {len(sections)} sections to {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py new file mode 100644 index 0000000000..1817ab0760 --- /dev/null +++ b/tests/unittest/test_config_reference.py @@ -0,0 +1,43 @@ +import re +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CONFIG_TOML = ROOT / "pr_agent/settings/configuration.toml" +OUTPUT_PAGE = ROOT / "docs/docs/usage-guide/configuration_reference.md" + + +def _toml_keys(text: str) -> Counter: + return Counter(re.findall(r"^([a-zA-Z0-9_]+)\s*=", text, re.M)) + + +def _documented_keys(text: str) -> Counter: + return Counter( + row.group(1) + for line in text.splitlines() + if line.startswith("| ") + for row in [re.match(r"\| `([^`]+)` \|", line)] + if row + ) + + +def test_config_reference_covers_every_active_key(): + keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) + assert sum(keys.values()) == 292 + + assert { + "model", + "enable_auto_approval", + "reaction_on_start", + "reaction_on_failure", + } <= set(keys) + assert {"force_streaming_custom_llm_provider", "cache_control_injection_points"} <= set(keys) + + +def test_config_reference_page_lists_every_active_key(): + toml_keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) + documented = _documented_keys(OUTPUT_PAGE.read_text(encoding="utf-8")) + assert documented == toml_keys, ( + "docs/docs/usage-guide/configuration_reference.md is out of date; " + "run scripts/generate_config_reference.py" + ) From db06a902a09d7d4a6662f820dcf92c394d7c3bce Mon Sep 17 00:00:00 2001 From: Mihailchik <129635161+Mihailchik@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:26:15 +0700 Subject: [PATCH 027/238] test(docs): guard mkdocs and GitBook navs against drift (#3231) Co-authored-by: mihailchkik --- tests/unittest/test_docs_nav_parity.py | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/unittest/test_docs_nav_parity.py diff --git a/tests/unittest/test_docs_nav_parity.py b/tests/unittest/test_docs_nav_parity.py new file mode 100644 index 0000000000..ae1684a3a3 --- /dev/null +++ b/tests/unittest/test_docs_nav_parity.py @@ -0,0 +1,77 @@ +"""Guards for issue #3198: keep the two documentation navigations in step. + +The docs are rendered twice. `docs/mkdocs.yml` drives the mkdocs site, and +`docs/docs/summary.md` is the GitBook table of contents, wired up in +`docs/docs/.gitbook.yaml` as `summary: ./summary.md`. `extending_pr_agent.md` +tells contributors to register every new page in both, so the two files are +expected to list exactly the same set of pages — and nothing enforces that +today. These tests do. +""" + +import re +from pathlib import Path + +DOCS = Path(__file__).resolve().parents[2] / "docs" +PAGES = DOCS / "docs" +MKDOCS = DOCS / "mkdocs.yml" +SUMMARY = PAGES / "summary.md" + +# Pages that are deliberately outside both navigations. +NOT_IN_NAV = { + # summary.md is a navigation file itself, not a page of the site. + "summary.md", + # An example best_practices.md for a Qodo Merge feature this package does + # not implement. It ships as a sample for users to copy, not as a docs + # page, and pr_agent/tools/pr_help_message.py excludes it by name for the + # same reason. + "usage-guide/EXAMPLE_BEST_PRACTICE.md", +} + + +def _mkdocs_pages() -> set[str]: + """Page paths listed in the mkdocs nav, ignoring commented-out entries.""" + lines = [ + line + for line in MKDOCS.read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#") + ] + return set(re.findall(r"'([^']+\.md)'", "\n".join(lines))) + + +def _summary_pages() -> set[str]: + """Page paths linked from the GitBook summary.""" + return set(re.findall(r"\(([^()]+\.md)\)", SUMMARY.read_text(encoding="utf-8"))) + + +def test_every_page_is_reachable_from_a_nav(): + """No page under docs/docs is orphaned from both navigations.""" + on_disk = {p.relative_to(PAGES).as_posix() for p in PAGES.rglob("*.md")} + reachable = _mkdocs_pages() | _summary_pages() | NOT_IN_NAV + assert not on_disk - reachable, ( + "pages exist but no navigation points at them: " + f"{sorted(on_disk - reachable)}" + ) + + +def test_navs_list_the_same_pages(): + """mkdocs.yml and summary.md must not drift apart.""" + mkdocs, summary = _mkdocs_pages(), _summary_pages() + assert not mkdocs - summary, ( + f"in mkdocs.yml but missing from summary.md: {sorted(mkdocs - summary)}" + ) + assert not summary - mkdocs, ( + f"in summary.md but missing from mkdocs.yml: {sorted(summary - mkdocs)}" + ) + + +def test_navs_do_not_point_at_missing_files(): + """Every navigation entry resolves to a file that exists.""" + for label, pages in (("mkdocs.yml", _mkdocs_pages()), ("summary.md", _summary_pages())): + missing = sorted(page for page in pages if not (PAGES / page).exists()) + assert not missing, f"{label} references missing pages: {missing}" + + +def test_declared_exclusions_still_exist(): + """Keeps NOT_IN_NAV honest: a stale entry there would hide a real orphan.""" + missing = sorted(name for name in NOT_IN_NAV if not (PAGES / name).exists()) + assert not missing, f"NOT_IN_NAV lists files that no longer exist: {missing}" From cb4177c630c8eb6aa017d46b72fbd149c55e1ae8 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:28:23 +0800 Subject: [PATCH 028/238] docs: correct stale and inaccurate documentation and comments (#3250) Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- docs/docs/usage-guide/changing_a_model.md | 4 ++-- .../usage-guide/configuration_reference.md | 7 +++++-- pr_agent/agent/pr_agent.py | 2 +- .../algo/ai_handlers/litellm_ai_handler.py | 21 +++++++------------ pr_agent/algo/pr_processing.py | 6 +++--- pr_agent/custom_merge_loader.py | 4 ++-- pr_agent/git_providers/git_provider.py | 9 ++------ pr_agent/settings/.secrets_template.toml | 4 ++-- pr_agent/settings/configuration.toml | 4 ++-- pr_agent/tools/pr_config.py | 3 ++- pyproject.toml | 8 ++++--- tests/unittest/test_config_reference.py | 2 +- .../test_litellm_openrouter_controls.py | 2 +- .../unittest/test_litellm_reasoning_effort.py | 2 +- 15 files changed, 37 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6368bb2d0..6139521989 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ Ruff is the single linting tool: `pyproject.toml` configures it and the pre-comm - Follow `CONTRIBUTING.md`: keep changes focused, add or update tests, and use Conventional Commit-style messages (e.g., `fix: handle missing repo settings gracefully`). - Target branch names follow `feature/` or `fix/` patterns for substantial work. - Reference related issues and update README or docs when user-facing behavior shifts. -- Ensure CI workflows (`build-and-test`, `code-coverage`, `docs-ci`) succeed locally or in draft PRs before requesting review; reproduce failures with the documented commands above. +- Before requesting review, run the relevant local checks above and make sure the corresponding CI workflows (`build-and-test`, `pre-commit`) pass; coverage is collected and uploaded by `build-and-test`, and `docs-ci` runs only on pushes to `main` and `add-docs-portal`. - Include screenshots or terminal captures when modifying user-visible output or documentation previews. ## Safety and Permissions diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 15b1d7a7fc..289d4c78c2 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -43,7 +43,7 @@ To reduce costs for non-urgent/background tasks, enable Flex Processing: ```toml [litellm] -extra_body='{"processing_mode": "flex"}' +extra_body='{"service_tier": "flex"}' ``` See [OpenAI Flex Processing docs](https://platform.openai.com/docs/guides/flex-processing) for details. @@ -554,7 +554,7 @@ For `openrouter/...` models you can optionally restrict which upstream providers # max_tokens = 16000 # hard cap on completion tokens for the request ``` -`provider_only` and `reasoning_effort = "none"` are useful to pin a specific provider and to bound the cost of reasoning models. Because Openrouter treats effort and token budgets as mutually exclusive, an explicit Openrouter-specific `"none"` keeps reasoning disabled when the model supports disabling it. Grok 4.5/4.6 clamp `"none"` before precedence is applied, so a positive budget wins there; otherwise a positive `reasoning_max_tokens` value takes precedence over the global effort and other Openrouter-specific values. Invalid Openrouter-specific effort values are warned about and treated as unset, so registered reasoning models fall back to `config.reasoning_effort`. Openrouter normalizes `"max"` to `"xhigh"` in this path to match LiteLLM 1.98.0. Supported effort values vary by model, and models whose metadata marks reasoning as mandatory reject `"none"`. For Anthropic models using a reasoning budget, set the effective output `max_tokens` higher than `reasoning_max_tokens` so the final answer has output headroom. See the Openrouter [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection) and [reasoning tokens](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) docs. +`provider_only` and `reasoning_effort = "none"` are useful to pin a specific provider and to bound the cost of reasoning models. Because Openrouter treats effort and token budgets as mutually exclusive, an explicit Openrouter-specific `"none"` keeps reasoning disabled when the model supports disabling it. Grok 4.5/4.6 clamp `"none"` before precedence is applied, so a positive budget wins there; otherwise a positive `reasoning_max_tokens` value takes precedence over the global effort and other Openrouter-specific values. Invalid Openrouter-specific effort values are warned about and treated as unset, so registered reasoning models fall back to `config.reasoning_effort`. Openrouter normalizes `"max"` to `"xhigh"` in this path for LiteLLM/OpenRouter compatibility. Supported effort values vary by model, and models whose metadata marks reasoning as mandatory reject `"none"`. For Anthropic models using a reasoning budget, set the effective output `max_tokens` higher than `reasoning_max_tokens` so the final answer has output headroom. See the Openrouter [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection) and [reasoning tokens](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) docs. ### OrcaRouter diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 0f16c6ce6f..d213c38dd5 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -167,7 +167,7 @@ to-do list. | `minimal_commits_for_incremental_review` | 0 | | | `minimal_minutes_for_incremental_review` | 0 | | | `enable_intro_text` | true | | -| `enable_help_text` | false | Determines whether to include help text in the PR review. Enabled by default. | +| `enable_help_text` | false | Determines whether to include help text in the PR review. | | `enable_review_coverage_footer` | true | | | `enable_large_pr_chunking` | false | large-diff chunking (opt-in). When the token budget leaves files out of the review, split the diff into chunks, review each chunk, and merge the per-chunk results into one review. | | `max_number_of_calls` | 3 | maximum number of chunk review calls, used only when enable_large_pr_chunking is true | @@ -362,6 +362,9 @@ _This section only documents commented-out examples; see the [TOML source](https | --- | --- | --- | | `deployment_type` | "user" | The type of deployment to create. Valid values are 'app' or 'user'. | | `ratelimit_retries` | 5 | | +| `seconds_between_requests` | 0 | seconds between API requests; 0 = no pacing (1.59 behaviour) | +| `seconds_between_writes` | 0 | seconds between write calls; 0 = no pacing (1.59 behaviour) | +| `api_retries` | 0 | max retries per request with backoff; 0 = no retries (1.59 behaviour) | | `polling_request_timeout` | 10 | total seconds for comment-history fallback; positive values capped at 60 | | `base_url` | "https://api.github.com" | | | `publish_inline_comments_fallback_with_verification` | true | | @@ -475,7 +478,7 @@ _This section only documents commented-out examples; see the [TOML source](https | `provider_only` | [] | restrict routing to these upstream providers only, a hard allowlist (e.g. ["z-ai"]); empty = OpenRouter default routing | | `provider_order` | [] | preferred provider order; ignored when provider_only is set; empty = unset | | `allow_fallbacks` | true | when provider_order is set, allow routing beyond the listed providers | -| `reasoning_effort` | "" | Invalid reasoning_effort values are warned about and treated as unset. Empty inherits config.reasoning_effort for models in SUPPORT_REASONING_EFFORT_MODELS. Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". OpenRouter normalizes "max" to "xhigh" to match LiteLLM 1.98.0. Model-specific support varies; mandatory reasoning models reject "none". | +| `reasoning_effort` | "" | Invalid reasoning_effort values are warned about and treated as unset. Empty inherits config.reasoning_effort for models in SUPPORT_REASONING_EFFORT_MODELS. Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". OpenRouter normalizes "max" to "xhigh" for LiteLLM/OpenRouter compatibility. Model-specific support varies; mandatory reasoning models reject "none". | | `reasoning_max_tokens` | 0 | A positive value overrides global effort and non-none OpenRouter-specific efforts. Explicit openrouter.reasoning_effort = "none" keeps reasoning disabled, except on Grok 4.5/4.6: there "none" is clamped to the lowest supported effort first, so a positive budget wins over it. Some providers require max_tokens to be greater than the reasoning budget. | | `max_tokens` | 0 | hard cap on completion tokens for the request; 0 = unset | diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index 2414055b6c..f5097d702a 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -178,7 +178,7 @@ def prepare_command(command: str) -> list[str]: class PRAgent: def __init__(self, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler): - self.ai_handler = ai_handler # will be initialized in run_action + self.ai_handler = ai_handler # handler factory passed to each tool when it is instantiated async def _handle_request(self, pr_url, request, notify=None) -> bool: # Exceptions raised inside are caught below, but a BaseException (e.g. the diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 3d22f0dbf6..f41aacb2ed 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -82,17 +82,10 @@ def _should_retry_same_model(exc: BaseException) -> bool: class LiteLLMAIHandler(BaseAiHandler): - """ - This class handles interactions with the OpenAI API for chat completions. - It initializes the API key and other settings from a configuration file, - and provides a method for performing chat completions using the OpenAI ChatCompletion API. - """ + """Handle chat completions across supported providers through LiteLLM.""" def __init__(self): - """ - Initializes the OpenAI API key and other settings from a configuration file. - Raises a ValueError if the OpenAI key is missing. - """ + """Initialize provider credentials and request settings from configuration.""" self.azure = False self.api_base = None self.repetition_penalty = None @@ -626,7 +619,7 @@ def capture_logs(message): if extra.get("pr_url") is not None: log_entry.update({"pr_url": extra["pr_url"]}) - # Append the log entry to the captured_logs list + # Append the captured request context. captured_extra.append(log_entry) # Adding the custom sink to Loguru @@ -932,9 +925,9 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: reasoning_effort = clamped_effort if model.startswith("openrouter/"): - # LiteLLM 1.98.0 rejects top-level reasoning_effort for some - # OpenRouter model IDs it does not mark as reasoning-capable; - # defer to OpenRouter's unified reasoning object below. + # LiteLLM rejects top-level reasoning_effort for some OpenRouter + # model IDs it does not mark as reasoning-capable; defer to + # OpenRouter's unified reasoning object below. openrouter_reasoning_effort = reasoning_effort else: get_logger().info(f"Adding reasoning_effort with value {reasoning_effort} to model {model}.") @@ -947,7 +940,7 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: ) or [] except Exception: supported_params = [] - # LiteLLM 1.98.0 omits reasoning_effort for grok-build-latest + # LiteLLM may omit reasoning_effort for grok-build-latest # and OpenAI-compatible gateway-prefixed Grok IDs. if "reasoning_effort" not in supported_params: kwargs["allowed_openai_params"] = ["reasoning_effort"] diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index cad8513e90..a0d041b1b3 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -308,7 +308,7 @@ def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_mod patch_final = "" new_patch_tokens = 0 - # If the patch is too large, just show the file name + # If the patch is too large, leave the file in the remaining-files list. if total_tokens + new_patch_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: # Current logic is to skip the patch if it's too large # TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens @@ -405,7 +405,7 @@ def get_pr_multi_diffs(git_provider: GitProvider, git_provider (GitProvider): An object that provides access to Git provider APIs. token_handler (TokenHandler): An object that handles tokens in the context of a pull request. model (str): The name of the model. - max_calls (int, optional): The maximum number of calls to retrieve diff files. Defaults to 5. + max_calls (int, optional): Maximum number of groups for split diffs; the full-diff fast path may still return one group. Defaults to 5. return_remaining_files (bool, optional): Also return the files the token budget left out, in the same shape as `get_pr_diff`. Files without a patch, and delete-only files, are not reported: nothing was omitted for them. Defaults to False. @@ -426,7 +426,7 @@ def get_pr_multi_diffs(git_provider: GitProvider, PATCH_EXTRA_LINES_BEFORE = cap_and_log_extra_lines(PATCH_EXTRA_LINES_BEFORE, "before") PATCH_EXTRA_LINES_AFTER = cap_and_log_extra_lines(PATCH_EXTRA_LINES_AFTER, "after") - # try first a single run with standard diff string, with patch extension, and no deletions + # First try a single run with the full diff and extended patch context. patches_extended, total_tokens, patches_extended_tokens = pr_generate_extended_diff( pr_languages, token_handler, add_line_numbers_to_hunks=add_line_numbers, diff --git a/pr_agent/custom_merge_loader.py b/pr_agent/custom_merge_loader.py index 8dc7d54b89..ad88286a8e 100644 --- a/pr_agent/custom_merge_loader.py +++ b/pr_agent/custom_merge_loader.py @@ -19,10 +19,10 @@ def load(obj, env=None, silent=True, key=None, filename=None): - Supports Dynaconf's fresh_vars feature for dynamic reloading. Args: obj: The Dynaconf settings instance to update. - env: The current environment name (upper case). Defaults to 'DEVELOPMENT'. Note: currently unused. + env: Unused compatibility parameter. Defaults to None. silent (bool): If True, suppress exceptions and log warnings/errors instead. key (str | None): Load only this top-level key (section) if provided; otherwise, load all keys from the files. - filename (str | None): Custom filename for tests (not used when settings_files are provided). + filename (str | None): Unused compatibility parameter. Settings files come from the Dynaconf object. Returns: None """ diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index bb2bc57837..0b7d5f4060 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -1,4 +1,3 @@ -# enum EDIT_TYPE (ADDED, DELETED, MODIFIED, RENAMED) import os import re import shutil @@ -236,13 +235,9 @@ def _prepare_clone_url_with_token(self, repo_url_to_clone: str) -> str | None: get_logger().warning("Not implemented! Returning None") return None - # Does a shallow clone, using a forked process to support a timeout guard. - # In case operation has failed, it is expected to throw an exception as this method does not return a value. + # Run a shallow, blob-filtered clone in a subprocess so the timeout can terminate the operation. + # Failures propagate to clone(), which handles and logs them. def _clone_inner(self, repo_url: str, dest_folder: str, operation_timeout_in_seconds: int=None) -> None: - #The following ought to be equivalent to: - # #Repo.clone_from(repo_url, dest_folder) - # , but with throwing an exception upon timeout. - # Note: This can only be used in context that supports using pipes. try: ssl_env = get_git_ssl_env() except Exception as e: diff --git a/pr_agent/settings/.secrets_template.toml b/pr_agent/settings/.secrets_template.toml index 065fdcb909..a337dfb950 100644 --- a/pr_agent/settings/.secrets_template.toml +++ b/pr_agent/settings/.secrets_template.toml @@ -2,7 +2,7 @@ # Copy this file to .secrets.toml in the same folder. # The minimum workable settings - set openai.key to your API key. # Set github.deployment_type to "user" and github.user_token to your GitHub personal access token. -# This will allow you to run the CLI scripts in the scripts/ folder and the github_polling server. +# This will allow you to run the pr-agent CLI and the github_polling server. # # See README for details about GitHub App deployment. @@ -18,7 +18,7 @@ key = "" # Acquire through https://platform.openai.com # OpenAI Flex Processing (optional, for cost savings) # [litellm] -# extra_body='{"processing_mode": "flex"}' +# extra_body='{"service_tier": "flex"}' # model_id = "" # Optional: Custom inference profile ID for Amazon Bedrock [pinecone] diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 6d310fa225..c68efeb168 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -146,7 +146,7 @@ require_all_thresholds_for_incremental_review=false minimal_commits_for_incremental_review=0 minimal_minutes_for_incremental_review=0 enable_intro_text=true -enable_help_text=false # Determines whether to include help text in the PR review. Enabled by default. +enable_help_text=false # Determines whether to include help text in the PR review. enable_review_coverage_footer=true # large-diff chunking (opt-in). When the token budget leaves files out of the review, split the diff # into chunks, review each chunk, and merge the per-chunk results into one review. @@ -444,7 +444,7 @@ allow_fallbacks = true # when provider_order is set, allow routing beyond the li # Invalid reasoning_effort values are warned about and treated as unset. # Empty inherits config.reasoning_effort for models in SUPPORT_REASONING_EFFORT_MODELS. # Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". -# OpenRouter normalizes "max" to "xhigh" to match LiteLLM 1.98.0. +# OpenRouter normalizes "max" to "xhigh" for LiteLLM/OpenRouter compatibility. # Model-specific support varies; mandatory reasoning models reject "none". reasoning_effort = "" # A positive value overrides global effort and non-none OpenRouter-specific efforts. diff --git a/pr_agent/tools/pr_config.py b/pr_agent/tools/pr_config.py index 293db20e95..a4080f4643 100644 --- a/pr_agent/tools/pr_config.py +++ b/pr_agent/tools/pr_config.py @@ -15,7 +15,8 @@ def __init__(self, pr_url: str, args=None, ai_handler=None): Args: pr_url (str): The URL of the pull request to be reviewed. - args (list, optional): List of arguments passed to the PRReviewer class. Defaults to None. + args (list, optional): Unused compatibility parameter for the common tool interface. Defaults to None. + ai_handler: Unused compatibility parameter for the common tool interface. Defaults to None. """ self.git_provider = get_git_provider()(pr_url) diff --git a/pyproject.toml b/pyproject.toml index 49441158bb..03e4281cc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,10 +43,12 @@ dependencies = [ "starlette>=1.6.0,<2", "google-cloud-aiplatform==1.154.0", # google-cloud-aiplatform==1.154.0 requires storage <4,>=1.32.0 on py<3.13 and <4,>=3.10.0 on py>=3.13. - # Pin per-Python (exact, like the rest of this list) so existing installs are unchanged and py3.13 resolves. See #2480. + # Pin per-Python so existing installs are unchanged and py3.13 resolves. See #2480. "google-cloud-storage==2.10.0; python_version < '3.13'", "google-cloud-storage==3.12.0; python_version >= '3.13'", - "protobuf==6.33.6", # grpcio-status 1.80.0 forces >=6.31.1,<7; pinned to lock the resolved transitive version + # Keep protobuf explicit to lock the transitive version resolved with the gRPC dependency stack. + # Revalidate compatibility when updating grpcio-status or related Google/gRPC dependencies. + "protobuf==6.33.6", "Jinja2==3.1.6", "litellm==1.100.0", "loguru==0.7.2", @@ -144,7 +146,7 @@ required-version = "==0.12.10" line-length = 120 lint.select = [ - "E", # Pyflakes + "E", # pycodestyle "F", # Pyflakes "B", # flake8-bugbear "I", # isort diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index 1817ab0760..b619953600 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 292 + assert sum(keys.values()) == 295 assert { "model", diff --git a/tests/unittest/test_litellm_openrouter_controls.py b/tests/unittest/test_litellm_openrouter_controls.py index 36b74bbcb7..831ca90c13 100644 --- a/tests/unittest/test_litellm_openrouter_controls.py +++ b/tests/unittest/test_litellm_openrouter_controls.py @@ -339,7 +339,7 @@ async def test_reasoning_budget_suppresses_global_effort(self, monkeypatch): assert kwargs["extra_body"]["reasoning"] == {"max_tokens": 2048} def test_litellm_requires_openrouter_reasoning_in_extra_body(self): - """Pin the LiteLLM 1.98.0 workaround boundary so upgrades expose when it can be removed.""" + """Pin the LiteLLM compatibility boundary so upgrades expose when it can be removed.""" with pytest.raises(litellm.UnsupportedParamsError): get_optional_params( model="google/gemini-2.5-pro", diff --git a/tests/unittest/test_litellm_reasoning_effort.py b/tests/unittest/test_litellm_reasoning_effort.py index 0153812936..a919449726 100644 --- a/tests/unittest/test_litellm_reasoning_effort.py +++ b/tests/unittest/test_litellm_reasoning_effort.py @@ -1141,7 +1141,7 @@ def test_clamp_grok_reasoning_effort(self, model, configured, expected): ], ) def test_xai_grok_litellm_reasoning_param_support(self, monkeypatch, model, effort, requires_allowlist): - """Pin LiteLLM 1.98 capability gaps so upgrades expose removable workarounds.""" + """Pin LiteLLM capability gaps so upgrades expose removable workarounds.""" monkeypatch.setattr(litellm, "drop_params", False) bundled_model_cost = GetModelCostMap.load_local_model_cost_map() pinned_model_cost = dict(litellm.model_cost) From b2d3dde776e74ee2aa27341254e615a683975c19 Mon Sep 17 00:00:00 2001 From: Machen John Date: Thu, 10 Sep 2026 11:06:24 +0100 Subject: [PATCH 029/238] docs: remove stale best practices guidance (#3202) Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- docs/docs/tools/improve.md | 40 ++-- .../docs/usage-guide/EXAMPLE_BEST_PRACTICE.md | 190 ------------------ .../docs/usage-guide/configuration_options.md | 2 +- pr_agent/tools/pr_help_message.py | 2 +- tests/unittest/test_docs_nav_parity.py | 5 - 5 files changed, 17 insertions(+), 222 deletions(-) delete mode 100644 docs/docs/usage-guide/EXAMPLE_BEST_PRACTICE.md diff --git a/docs/docs/tools/improve.md b/docs/docs/tools/improve.md index 00396989ee..343804f00d 100644 --- a/docs/docs/tools/improve.md +++ b/docs/docs/tools/improve.md @@ -122,25 +122,20 @@ Use triple quotes to write multi-line instructions. Use bullet points or numbers ### Best practices -`Platforms supported: GitHub, GitLab, Bitbucket` +`Repository context files supported: GitHub, GitLab, Gitea, Bitbucket, Azure DevOps` -!!! warning "Open-source PR-Agent" +The open-source PR-Agent package does not load `best_practices.md` automatically. To use a best-practices file, +add it to `config.repo_context_files`: - Automatic loading of `best_practices.md` is a Qodo Merge feature and is not available in the open-source - PR-Agent package. In the open-source package, add the file to `config.repo_context_files` instead: - - ```toml - [config] - repo_context_files = ["AGENTS.md", "best_practices.md"] - ``` - - This fallback supports GitHub, GitLab, Gitea, Bitbucket, and Azure DevOps. Repository context files are read - from the default branch by default and are limited by - `config.repo_context_max_lines` (500 lines by default). Set `config.repo_context_from_default_branch = false` - to read them from the pull request's target branch instead. Providers without repository file fetching log a - warning and skip this context. +```toml +[config] +repo_context_files = ["AGENTS.md", "best_practices.md"] +``` -Qodo Merge supports both simple and hierarchical best practices configurations to provide guidance to the AI model for generating relevant code suggestions. +This fallback supports GitHub, GitLab, Gitea, Bitbucket, and Azure DevOps. Repository context files are read +from the default branch by default and are limited by `config.repo_context_max_lines` (500 lines by default). +Set `config.repo_context_from_default_branch = false` to read them from the pull request's target branch instead. +Providers without repository file fetching log a warning and skip this context. ???- tip "Writing effective best practices files" @@ -149,10 +144,11 @@ Qodo Merge supports both simple and hierarchical best practices configurations t - Write clearly and concisely - Include brief code examples when helpful with before/after patterns - Focus on project-specific guidelines that will result in relevant suggestions you actually want to get - - Keep each file relatively short, under 800 lines, since: + - Keep each file short and focused, since: - AI models may not process effectively very long documents - Long files tend to contain generic guidelines already known to AI - - Maximum multiple file accumulated content is limited to 2000 lines. + - `config.repo_context_max_lines` limits the total rendered repository context, including wrapper and + truncation lines. - Use pattern-based structure rather than simple bullet points for better clarity ???- tip "Example of a best practices file" @@ -198,17 +194,11 @@ Qodo Merge supports both simple and hierarchical best practices configurations t return "" ``` -#### Local best practices in Qodo Merge - -For basic usage, create a `best_practices.md` file in your repository's root directory containing a list of best practices, coding standards, and guidelines specific to your repository. - -The AI model will use this `best_practices.md` file as a reference, and in case the PR code violates any of the guidelines, it will create additional suggestions, with a dedicated label: `Organization best practice`. - ### Combining 'extra instructions' and 'best practices' The `extra instructions` configuration is more related to the `improve` tool prompt. It can be used, for example, to avoid specific suggestions ("Don't suggest to add try-except block", "Ignore changes in toml files", ...) or to emphasize specific aspects or formats ("Answer in Japanese", "Give only short suggestions", ...) -In contrast, the `best_practices.md` file is a general guideline for the way code should be written in the repo. +In contrast, a `best_practices.md` file is a general guideline for the way code should be written in the repo. Using a combination of both can help the AI model to provide relevant and tailored suggestions. diff --git a/docs/docs/usage-guide/EXAMPLE_BEST_PRACTICE.md b/docs/docs/usage-guide/EXAMPLE_BEST_PRACTICE.md deleted file mode 100644 index 993c0246db..0000000000 --- a/docs/docs/usage-guide/EXAMPLE_BEST_PRACTICE.md +++ /dev/null @@ -1,190 +0,0 @@ -## Recommend Python Best Practices - -This document outlines a series of recommended best practices for Python development. These guidelines aim to improve code quality, maintainability, and readability. - -### Imports - -Use `import` statements for packages and modules only, not for individual types, classes, or functions. - -#### Definition - -Reusability mechanism for sharing code from one module to another. - -#### Decision - -- Use `import x` for importing packages and modules. -- Use `from x import y` where `x` is the package prefix and `y` is the module name with no prefix. -- Use `from x import y as z` in any of the following circumstances: - - Two modules named `y` are to be imported. - - `y` conflicts with a top-level name defined in the current module. - - `y` conflicts with a common parameter name that is part of the public API (e.g., `features`). - - `y` is an inconveniently long name, or too generic in the context of your code -- Use `import y as z` only when `z` is a standard abbreviation (e.g., `import numpy as np`). - -For example the module `sound.effects.echo` may be imported as follows: - -``` -from sound.effects import echo -... -echo.EchoFilter(input, output, delay=0.7, atten=4) - -``` - -Do not use relative names in imports. Even if the module is in the same package, use the full package name. This helps prevent unintentionally importing a package twice. - -##### Exemptions - -Exemptions from this rule: - -- Symbols from the following modules are used to support static analysis and type checking: - - [`typing` module](https://google.github.io/styleguide/pyguide.html#typing-imports) - - [`collections.abc` module](https://google.github.io/styleguide/pyguide.html#typing-imports) - - [`typing_extensions` module](https://github.com/python/typing_extensions/blob/main/README.md) -- Redirects from the [six.moves module](https://six.readthedocs.io/#module-six.moves). - -### Packages - -Import each module using the full pathname location of the module. - -#### Decision - -All new code should import each module by its full package name. - -Imports should be as follows: - -``` -Yes: - # Reference absl.flags in code with the complete name (verbose). - import absl.flags - from doctor.who import jodie - - _FOO = absl.flags.DEFINE_string(...) - -``` - -``` -Yes: - # Reference flags in code with just the module name (common). - from absl import flags - from doctor.who import jodie - - _FOO = flags.DEFINE_string(...) - -``` - -_(assume this file lives in `doctor/who/` where `jodie.py` also exists)_ - -``` -No: - # Unclear what module the author wanted and what will be imported. The actual - # import behavior depends on external factors controlling sys.path. - # Which possible jodie module did the author intend to import? - import jodie - -``` - -The directory the main binary is located in should not be assumed to be in `sys.path` despite that happening in some environments. This being the case, code should assume that `import jodie` refers to a third-party or top-level package named `jodie`, not a local `jodie.py`. - -### Default Iterators and Operators - -Use default iterators and operators for types that support them, like lists, dictionaries, and files. - -#### Definition - -Container types, like dictionaries and lists, define default iterators and membership test operators (“in” and “not in”). - -#### Decision - -Use default iterators and operators for types that support them, like lists, dictionaries, and files. The built-in types define iterator methods, too. Prefer these methods to methods that return lists, except that you should not mutate a container while iterating over it. - -``` -Yes: for key in adict: ... - if obj in alist: ... - for line in afile: ... - for k, v in adict.items(): ... -``` - -``` -No: for key in adict.keys(): ... - for line in afile.readlines(): ... -``` - -### Lambda Functions - -Okay for one-liners. Prefer generator expressions over `map()` or `filter()` with a `lambda`. - -#### Decision - -Lambdas are allowed. If the code inside the lambda function spans multiple lines or is longer than 60-80 chars, it might be better to define it as a regular [nested function](https://google.github.io/styleguide/pyguide.html#lexical-scoping). - -For common operations like multiplication, use the functions from the `operator` module instead of lambda functions. For example, prefer `operator.mul` to `lambda x, y: x * y`. - -### Default Argument Values - -Okay in most cases. - -#### Definition - -You can specify values for variables at the end of a function’s parameter list, e.g., `def foo(a, b=0):`. If `foo` is called with only one argument, `b` is set to 0. If it is called with two arguments, `b` has the value of the second argument. - -#### Decision - -Okay to use with the following caveat: - -Do not use mutable objects as default values in the function or method definition. - -``` -Yes: def foo(a, b=None): - if b is None: - b = [] -Yes: def foo(a, b: Sequence | None = None): - if b is None: - b = [] -Yes: def foo(a, b: Sequence = ()): # Empty tuple OK since tuples are immutable. - ... -``` - -``` -from absl import flags -_FOO = flags.DEFINE_string(...) - -No: def foo(a, b=[]): - ... -No: def foo(a, b=time.time()): # Is `b` supposed to represent when this module was loaded? - ... -No: def foo(a, b=_FOO.value): # sys.argv has not yet been parsed... - ... -No: def foo(a, b: Mapping = {}): # Could still get passed to unchecked code. - ... -``` - -### True/False Evaluations - -Use the “implicit” false if possible, e.g., `if foo:` rather than `if foo != []:` - -### Lexical Scoping - -Okay to use. - -An example of the use of this feature is: - -``` -def get_adder(summand1: float) -> Callable[[float], float]: - """Returns a function that adds numbers to a given number.""" - def adder(summand2: float) -> float: - return summand1 + summand2 - - return adder -``` - -#### Decision - -Okay to use. - -### Threading - -Do not rely on the atomicity of built-in types. - -While Python’s built-in data types such as dictionaries appear to have atomic operations, there are corner cases where they aren’t atomic (e.g. if `__hash__` or `__eq__` are implemented as Python methods) and their atomicity should not be relied upon. Neither should you rely on atomic variable assignment (since this in turn depends on dictionaries). - -Use the `queue` module’s `Queue` data type as the preferred way to communicate data between threads. Otherwise, use the `threading` module and its locking primitives. Prefer condition variables and `threading.Condition` instead of using lower-level locks. diff --git a/docs/docs/usage-guide/configuration_options.md b/docs/docs/usage-guide/configuration_options.md index 0f34d535a1..43ce5aed12 100644 --- a/docs/docs/usage-guide/configuration_options.md +++ b/docs/docs/usage-guide/configuration_options.md @@ -117,7 +117,7 @@ Create a dedicated project to hold a global configuration file that affects all 1. Create a new project with both the name and key: PR_AGENT_SETTINGS. 2. Inside the PR_AGENT_SETTINGS project, create a repository named pr-agent-settings. 3. In this repository, add a `.pr_agent.toml` configuration file—structured similarly to the global configuration file described above. -4. Optionally, you can add organizational-level [global best practices](../tools/improve.md#global-hierarchical-best-practices). +4. Optionally, you can add repository context files with shared instructions or best practices. Repositories across your entire Bitbucket organization will inherit the configuration from this file. diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 1d909617ad..d105d213d5 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -99,7 +99,7 @@ async def run(self): # get all the 'md' files inside docs_path and its subdirectories md_files = list(docs_path.glob('**/*.md')) folders_to_exclude = ['/finetuning_benchmark/'] - files_to_exclude = {'EXAMPLE_BEST_PRACTICE.md', 'compression_strategy.md', '/docs/overview/index.md'} + files_to_exclude = {'compression_strategy.md', '/docs/overview/index.md'} md_files = [file for file in md_files if not any(folder in str(file) for folder in folders_to_exclude) and not any(file.name == file_to_exclude for file_to_exclude in files_to_exclude)] # sort the 'md_files' so that 'priority_files' will be at the top diff --git a/tests/unittest/test_docs_nav_parity.py b/tests/unittest/test_docs_nav_parity.py index ae1684a3a3..33b3ee8578 100644 --- a/tests/unittest/test_docs_nav_parity.py +++ b/tests/unittest/test_docs_nav_parity.py @@ -20,11 +20,6 @@ NOT_IN_NAV = { # summary.md is a navigation file itself, not a page of the site. "summary.md", - # An example best_practices.md for a Qodo Merge feature this package does - # not implement. It ships as a sample for users to copy, not as a docs - # page, and pr_agent/tools/pr_help_message.py excludes it by name for the - # same reason. - "usage-guide/EXAMPLE_BEST_PRACTICE.md", } From 6e13fa66ab1d85ad3e06950e2f3806be8bb5b3a2 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:52:39 +0545 Subject: [PATCH 030/238] fix(similar_issue): port pinecone backend to the current SDK (#3227) Co-authored-by: Utsab Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- docs/docs/tools/similar_issues.md | 19 +- .../usage-guide/configuration_reference.md | 8 +- pr_agent/settings/.secrets_template.toml | 3 +- pr_agent/settings/configuration.toml | 7 +- pr_agent/tools/pr_similar_issue.py | 71 +++---- pyproject.toml | 8 +- tests/unittest/test_config_reference.py | 2 +- .../unittest/test_similar_issue_embeddings.py | 6 +- tests/unittest/test_similar_issue_pinecone.py | 150 ++++++++++++++ .../test_similar_issue_qdrant_collection.py | 2 +- uv.lock | 190 ++++++------------ 11 files changed, 275 insertions(+), 191 deletions(-) create mode 100644 tests/unittest/test_similar_issue_pinecone.py diff --git a/docs/docs/tools/similar_issues.md b/docs/docs/tools/similar_issues.md index 741c79938b..9d3814ed0c 100644 --- a/docs/docs/tools/similar_issues.md +++ b/docs/docs/tools/similar_issues.md @@ -36,10 +36,25 @@ To use Pinecone with the `similar issue` tool, add these credentials to `.secret ``` [pinecone] api_key = "..." -environment = "..." +cloud = "aws" +region = "us-east-1" ``` -These parameters can be obtained by registering to [Pinecone](https://app.pinecone.io/?sessionType=signup/). +The `cloud` value must be one of `aws`, `gcp` or `azure`, and `region` must be an +availability region offered by that cloud. These parameters can be obtained by +registering to [Pinecone](https://app.pinecone.io/?sessionType=signup/). Note that the +tool uses Pinecone's serverless index API; the former `environment` setting from the +gcp-starter pod tier is no longer supported. + +`cloud` and `region` are only used when the index does not exist yet and needs to be +created. An existing index is opened by name and is never recreated, so moving an +existing deployment to the new configuration does not lose the stored vectors. + +!!! note "Default vector database" + + `vectordb` defaults to `lancedb`, which works with no external credentials. To use + qdrant or pinecone, set `vectordb = "qdrant"` or `vectordb = "pinecone"` under + `[pr_similar_issue]`. #### Qdrant Configuration diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index d213c38dd5..c83ae79197 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -512,7 +512,7 @@ _This section only documents commented-out examples; see the [TOML source](https | `skip_comments` | false | | | `force_update_dataset` | false | | | `max_issues_to_scan` | 500 | | -| `vectordb` | "pinecone" | options: "pinecone", "lancedb", "qdrant" | +| `vectordb` | "lancedb" | options: "pinecone", "lancedb", "qdrant" | ## `[pr_find_similar_component]` @@ -529,7 +529,11 @@ _This section only documents commented-out examples; see the [TOML source](https ## `[pinecone]` -_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ +| Key | Default | Description | +| --- | --- | --- | +| `cloud` | "aws" | Serverless index deployment for the modern SDK. `cloud` is one of "aws", "gcp" or "azure"; pick a `region` offered by that cloud. | +| `region` | "us-east-1" | | + ## `[lancedb]` diff --git a/pr_agent/settings/.secrets_template.toml b/pr_agent/settings/.secrets_template.toml index a337dfb950..14585ee955 100644 --- a/pr_agent/settings/.secrets_template.toml +++ b/pr_agent/settings/.secrets_template.toml @@ -23,7 +23,8 @@ key = "" # Acquire through https://platform.openai.com [pinecone] api_key = "..." -environment = "gcp-starter" +cloud = "aws" +region = "us-east-1" [qdrant] # For Qdrant Cloud or self-hosted Qdrant diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index c68efeb168..ff77c73b10 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -480,7 +480,7 @@ include_error_details = false # set to true to attach exception messages and rej skip_comments = false force_update_dataset = false max_issues_to_scan = 500 -vectordb = "pinecone" # options: "pinecone", "lancedb", "qdrant" +vectordb = "lancedb" # options: "pinecone", "lancedb", "qdrant" [pr_find_similar_component] class_name = "" @@ -493,7 +493,10 @@ number_of_results = 5 [pinecone] # fill and place in .secrets.toml #api_key = ... -# environment = "gcp-starter" +# Serverless index deployment for the modern SDK. `cloud` is one of "aws", +# "gcp" or "azure"; pick a `region` offered by that cloud. +cloud = "aws" +region = "us-east-1" [lancedb] uri = "./lancedb" diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index ec963fa28f..e7c26e6504 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -88,39 +88,36 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): if get_settings().pr_similar_issue.vectordb == "pinecone": try: import pinecone - except: - raise Exception("Please install 'pinecone' and 'pinecone_datasets' to use pinecone as vectordb") - # assuming pinecone api key and environment are set in secrets file + from pinecone import ServerlessSpec + except ImportError: + raise Exception("Please install the 'pinecone' package to use pinecone as vectordb") from None + # assuming pinecone api key, cloud and region are set in secrets file try: api_key = get_settings().pinecone.api_key - environment = get_settings().pinecone.environment + cloud = get_settings().pinecone.cloud + region = get_settings().pinecone.region except Exception: if not self.cli_mode: repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1]) issue_main = self.git_provider.repo_obj.get_issue(original_issue_number) - issue_main.create_comment("Please set pinecone api key and environment in secrets file") - raise Exception("Please set pinecone api key and environment in secrets file") + issue_main.create_comment("Please set pinecone api key, cloud and region in secrets file") + raise Exception("Please set pinecone api key, cloud and region in secrets file") + self.pc = pinecone.Pinecone(api_key=api_key) + self.pc_spec = ServerlessSpec(cloud=cloud, region=region) + self.pinecone_index = None # check if index exists, and if repo is already indexed run_from_scratch = False - if run_from_scratch: # for debugging - pinecone.init(api_key=api_key, environment=environment) - if index_name in pinecone.list_indexes(): - get_logger().info('Removing index...') - pinecone.delete_index(index_name) - get_logger().info('Done') - upsert = True - pinecone.init(api_key=api_key, environment=environment) - if index_name not in pinecone.list_indexes(): + if not self.pc.has_index(index_name): run_from_scratch = True upsert = False else: if get_settings().pr_similar_issue.force_update_dataset: upsert = True else: - pinecone_index = pinecone.Index(index_name=index_name) - res = pinecone_index.fetch([f"example_issue_{repo_name_for_index}"]).to_dict() + self.pinecone_index = self.pc.Index(name=index_name) + res = self.pinecone_index.fetch(ids=[f"example_issue_{repo_name_for_index}"]).to_dict() if res["vectors"]: upsert = False @@ -132,7 +129,7 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): get_logger().info('Done') self._update_index_with_issues(issues, repo_name_for_index, upsert=upsert) else: # update index if needed - pinecone_index = pinecone.Index(index_name=index_name) + self.pinecone_index = self.pc.Index(name=index_name) issues_to_update = [] issues_paginated_list = repo_obj.get_issues(state='all') counter = 1 @@ -142,7 +139,7 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): issue_str, comments, number = self._process_issue(issue) issue_key = f"issue_{number}" id = issue_key + "." + "issue" - res = pinecone_index.fetch([id]).to_dict() + res = self.pinecone_index.fetch(ids=[id]).to_dict() is_new_issue = True for vector in res["vectors"].values(): if vector['metadata']['repo'] == repo_name_for_index: @@ -337,10 +334,8 @@ async def run(self): score_list = [] if get_settings().pr_similar_issue.vectordb == "pinecone": - import pinecone - - pinecone_index = pinecone.Index(index_name=self.index_name) - res = pinecone_index.query(embeds[0], + pinecone_index = self.pc.Index(name=self.index_name) + res = pinecone_index.query(vector=embeds[0], top_k=5, filter={"repo": self.repo_name_for_index}, include_metadata=True).to_dict() @@ -451,8 +446,6 @@ def _process_issue(self, issue): def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=False): import pandas as pd - import pinecone - from pinecone_datasets import Dataset, DatasetMetadata get_logger().info('Processing issues...') corpus = Corpus() @@ -515,25 +508,23 @@ def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=Fal list_to_encode = df["text"].to_list() embeds = _embed_with_fallback(list_to_encode) df["values"] = embeds - meta = DatasetMetadata.empty() - meta.dense_model.dimension = len(embeds[0]) - ds = Dataset.from_pandas(df, meta) get_logger().info('Done') - api_key = get_settings().pinecone.api_key - environment = get_settings().pinecone.environment + vectors = [ + (row["id"], row["values"], row["metadata"]) + for row in df.to_dict(orient="records") + ] if not upsert: get_logger().info('Creating index from scratch...') - ds.to_pinecone_index(self.index_name, api_key=api_key, environment=environment) - time.sleep(15) # wait for pinecone to finalize indexing before querying - else: - get_logger().info('Upserting index...') - namespace = "" - batch_size: int = 100 - concurrency: int = 10 - pinecone.init(api_key=api_key, environment=environment) - ds._upsert_to_index(self.index_name, namespace, batch_size, concurrency) - time.sleep(5) # wait for pinecone to finalize upserting before querying + self.pc.create_index(name=self.index_name, dimension=len(embeds[0]), metric="cosine", spec=self.pc_spec, + timeout=120) + get_logger().info('Upserting index...') + self.pinecone_index = self.pc.Index(name=self.index_name) + self.pinecone_index.upsert(vectors=vectors, + namespace="", + batch_size=100, + max_concurrency=10) + time.sleep(5) # wait for pinecone to finalize upserting before querying get_logger().info('Done') def _update_table_with_issues(self, issues_list, repo_name_for_index, ingest=False): diff --git a/pyproject.toml b/pyproject.toml index 03e4281cc0..c546e48dbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,13 +118,11 @@ pr-agent = "pr_agent.cli:run" [dependency-groups] # 'similar issue' tool. Install with: uv sync --group similar-issue. -# Keep this a dependency group rather than a published extra: pinecone-datasets -# resolves from a git fork, which wheel metadata cannot reference (PyPI rejects -# direct URL deps), so a pip-installed extra would silently pull the wrong package. +# Keep this a dependency group rather than a published extra so the vector +# database drivers stay out of the base install. similar-issue = [ "pandas", # imported directly by the qdrant indexing path - "pinecone-client", - "pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main", + "pinecone==10.0.0", "lancedb==0.38.0", "qdrant-client==1.15.1", ] diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index b619953600..f4c0102832 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 295 + assert sum(keys.values()) == 297 assert { "model", diff --git a/tests/unittest/test_similar_issue_embeddings.py b/tests/unittest/test_similar_issue_embeddings.py index a09e880a24..c7035e6df7 100644 --- a/tests/unittest/test_similar_issue_embeddings.py +++ b/tests/unittest/test_similar_issue_embeddings.py @@ -1,6 +1,5 @@ """Embed with the openai>=1.0 client that requirements.txt pins.""" import inspect -import sys from types import SimpleNamespace import pytest @@ -102,7 +101,7 @@ def __init__(self, api_key=None): @pytest.mark.asyncio -async def test_pinecone_query_import_is_available_in_run(monkeypatch): +async def test_pinecone_query_branch_runs(monkeypatch): queried = [] class FakeIndex: @@ -110,7 +109,6 @@ def query(self, *args, **kwargs): queried.append(kwargs) return SimpleNamespace(to_dict=lambda: {"matches": []}) - monkeypatch.setitem(sys.modules, "pinecone", SimpleNamespace(Index=lambda **kwargs: FakeIndex())) monkeypatch.setattr(psi, "_embed", lambda texts: [[0.5]]) monkeypatch.setattr( psi, @@ -127,6 +125,7 @@ def query(self, *args, **kwargs): tool.issue_url = "https://github.com/example/repo/issues/1" tool.index_name = "issues" tool.repo_name_for_index = "example-repo" + tool.pc = SimpleNamespace(Index=lambda name: FakeIndex()) tool.git_provider = SimpleNamespace( _parse_issue_url=lambda url: ("example/repo", 1), repo_obj=SimpleNamespace(get_issue=lambda number: issue), @@ -134,3 +133,4 @@ def query(self, *args, **kwargs): assert await tool.run() is None assert queried, "run() never entered the pinecone branch" + assert queried[0]["vector"] == [0.5] diff --git a/tests/unittest/test_similar_issue_pinecone.py b/tests/unittest/test_similar_issue_pinecone.py new file mode 100644 index 0000000000..4456afa1c3 --- /dev/null +++ b/tests/unittest/test_similar_issue_pinecone.py @@ -0,0 +1,150 @@ +"""Focused unit tests for the pinecone backend of the similar issue tool.""" +import sys +from pathlib import Path +from types import SimpleNamespace + +import pr_agent.tools.pr_similar_issue as psi + + +class SettingsStub: + class pr_similar_issue: + skip_comments = True + + +class _PandasSeries(list): + @property + def values(self): + return list(self) + + def to_list(self): + return list(self) + + +class _PandasDataFrame: + def __init__(self, records): + self._records = list(records) + self._overrides = {} + + def __getitem__(self, column): + if column in self._overrides: + return _PandasSeries(self._overrides[column]) + return _PandasSeries([row[column] for row in self._records]) + + def __setitem__(self, column, values): + self._overrides[column] = list(values) + + def to_dict(self, orient="records"): + rows = [dict(record) for record in self._records] + for column, values in self._overrides.items(): + for row, value in zip(rows, values, strict=True): + row[column] = value + return rows + + +def _make_issue(number): + return SimpleNamespace( + pull_request=False, + title=f"Issue {number}", + body="Body", + number=number, + user=SimpleNamespace(login="user"), + created_at="2020-01-01", + get_comments=lambda: [], + ) + + +def _make_tool(pc): + tool = psi.PRSimilarIssue.__new__(psi.PRSimilarIssue) + tool.pc = pc + tool.pc_spec = None + tool.index_name = "codium-ai-pr-agent-issues" + tool.max_issues_to_scan = 100 + tool.token_handler = SimpleNamespace(count_tokens=lambda text: 1) + tool.pinecone_index = None + return tool + + +def _stub_embeddings(monkeypatch): + monkeypatch.setitem(sys.modules, "pandas", + SimpleNamespace(DataFrame=_PandasDataFrame)) + embedding = [0.5, 0.5] + monkeypatch.setattr(psi, "get_settings", lambda: SettingsStub) + monkeypatch.setattr(psi, "get_max_tokens", lambda model: 8192) + monkeypatch.setattr(psi, "_embed_with_fallback", + lambda texts: [embedding for _ in texts]) + monkeypatch.setattr(psi.time, "sleep", lambda seconds: None) + + +def test_pinecone_upsert_passes_batch_size_and_max_concurrency(monkeypatch): + calls = {} + + class FakeIndex: + def upsert(self, **kwargs): + calls.update(kwargs) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + + tool._update_index_with_issues([_make_issue(7)], "example-repo", upsert=True) + + assert calls["namespace"] == "" + assert calls["batch_size"] == 100 + assert calls["max_concurrency"] == 10 + assert [vector[0] for vector in calls["vectors"]] == [ + "example_issue_example-repo", + "issue_7.issue", + ] + + +def test_pinecone_upsert_path_skips_index_creation(monkeypatch): + created = [] + + class FakeIndex: + def upsert(self, **kwargs): + pass + + pc = SimpleNamespace( + Index=lambda name: FakeIndex(), + create_index=lambda **kwargs: created.append(kwargs), + ) + tool = _make_tool(pc) + _stub_embeddings(monkeypatch) + + tool._update_index_with_issues([_make_issue(7)], "example-repo", upsert=True) + + assert created == [] + + +def test_pinecone_create_index_path_builds_new_index_then_upserts(monkeypatch): + created = [] + upserted = [] + + class FakeIndex: + def upsert(self, **kwargs): + upserted.append(kwargs) + + pc = SimpleNamespace( + Index=lambda name: FakeIndex(), + create_index=lambda **kwargs: created.append(kwargs), + ) + tool = _make_tool(pc) + _stub_embeddings(monkeypatch) + + tool._update_index_with_issues([_make_issue(7)], "example-repo", upsert=False) + + assert len(created) == 1 + kwargs = created[0] + assert kwargs["name"] == tool.index_name + assert kwargs["dimension"] == 2 + assert kwargs["metric"] == "cosine" + assert kwargs["timeout"] == 120 + assert upserted, "expected the upsert to run after index creation" + + +def test_vectordb_defaults_to_lancedb(): + config_path = Path(psi.__file__).resolve().parents[2] / "pr_agent" / "settings" / "configuration.toml" + content = config_path.read_text(encoding="utf-8") + section = content.split("[pr_similar_issue]")[1].split("[")[0] + + assert 'vectordb = "lancedb"' in section + assert 'vectordb = "pinecone"' not in section diff --git a/tests/unittest/test_similar_issue_qdrant_collection.py b/tests/unittest/test_similar_issue_qdrant_collection.py index 8878a78f36..977f5645fe 100644 --- a/tests/unittest/test_similar_issue_qdrant_collection.py +++ b/tests/unittest/test_similar_issue_qdrant_collection.py @@ -23,7 +23,7 @@ def test_suffix_is_scoped_to_qdrant_only(): assert call_site in source assert 'index_name = self.index_name = "codium-ai-pr-agent-issues"' in source - assert "pinecone.Index(index_name=self.index_name)" in source + assert "self.pc.Index(name=self.index_name)" in source assert "self.db.create_table(self.index_name, data=df, mode=\"overwrite\")" in source assert "self.qdrant_collection_name" not in source.split("elif get_settings().pr_similar_issue.vectordb == \"qdrant\":")[0] diff --git a/uv.lock b/uv.lock index 2458d94fd9..200e6812f2 100644 --- a/uv.lock +++ b/uv.lock @@ -872,57 +872,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] -[[package]] -name = "gcsfs" -version = "2025.12.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "aiohttp" }, - { name = "decorator" }, - { name = "fsspec", version = "2025.12.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-auth" }, - { name = "google-auth-oauthlib" }, - { name = "google-cloud-storage", version = "2.10.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-cloud-storage-control" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3e/3e/453b42bbcda2177daba97ebc5202faf5c57cc0c2bb4aac7081c12b0471da/gcsfs-2025.12.0.tar.gz", hash = "sha256:9a9c1c32b7899a3967ba30a7e9422cdfda596266f03476cdf5545402b8af4cd5", size = 95148, upload-time = "2025-12-03T15:44:59.703Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/bf/e664cbeab8f2a8b097c0604252760410fde191fe6ac7d5081b29e601ac52/gcsfs-2025.12.0-py3-none-any.whl", hash = "sha256:e06aaec53797dc6b83d5cc90c4d3ae7247b4ee0cf8d8b1ce50e8d6b78e3a9aea", size = 41204, upload-time = "2025-12-03T15:44:58.464Z" }, -] - -[[package]] -name = "gcsfs" -version = "2026.7.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "aiohttp" }, - { name = "decorator" }, - { name = "fsspec", version = "2026.6.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-auth" }, - { name = "google-auth-oauthlib" }, - { name = "google-cloud-storage", version = "3.12.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-cloud-storage-control" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/33/58e23c6f492e091c2f01ae1bffe331aa97e18a0b3d24ff90ef648335ddc8/gcsfs-2026.7.0.tar.gz", hash = "sha256:2df36ce1e9010e76dc4295774030495a97496838483c619e9f63bc0ab7bdc770", size = 1265874, upload-time = "2026-07-06T15:02:30.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/eb/a5c4b39903928e6be7735cc7aa31b6c3bbf3d887419e1e7ac751dba02233/gcsfs-2026.7.0-py3-none-any.whl", hash = "sha256:8ce6c08ea64302d8fd4bc23a615ccd9e7752541cde1a88ec8362109bd79cde78", size = 91131, upload-time = "2026-07-06T15:02:29.208Z" }, -] - [[package]] name = "gitdb" version = "4.0.12" @@ -1002,19 +951,6 @@ requests = [ { name = "requests" }, ] -[[package]] -name = "google-auth-oauthlib" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "requests-oauthlib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, -] - [[package]] name = "google-cloud-aiplatform" version = "1.154.0" @@ -1133,23 +1069,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/bd/a89eaebd2f9db5f92ddcc8e4f23c266be1dbd11058bb83451d8dd029f34c/google_cloud_storage-3.12.0-py3-none-any.whl", hash = "sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c", size = 340605, upload-time = "2026-06-12T18:03:12.677Z" }, ] -[[package]] -name = "google-cloud-storage-control" -version = "1.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/ae/707995271f77e3c1da715c740160f98f87a79a5c601b28d17c4d2500c037/google_cloud_storage_control-1.12.0.tar.gz", hash = "sha256:49090d03532c0c84c6246a5fd490c31fd4bbffb4c7771c0a6744ec23a194a5f6", size = 137036, upload-time = "2026-06-03T16:14:00.921Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/ba/f187f30fb2c8bb5dbb0de0fcb0dec2f8fab0b782ab42facc83ea02628f1b/google_cloud_storage_control-1.12.0-py3-none-any.whl", hash = "sha256:20f7f1252fa5635d47e12f746aa69d719e31eb9405cbbd14cdfba317db27d421", size = 102205, upload-time = "2026-06-03T16:12:43.266Z" }, -] - [[package]] name = "google-crc32c" version = "1.8.0" @@ -1996,6 +1915,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + [[package]] name = "msrest" version = "0.7.1" @@ -2452,45 +2411,24 @@ wheels = [ ] [[package]] -name = "pinecone-client" -version = "6.0.0" +name = "pinecone" +version = "10.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "pinecone-plugin-interface" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, - { name = "urllib3", marker = "python_full_version < '4'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6c/ab/3ab3b81e8ad82fbfcaa4f446c7f962b18968d61543c8c9e2c38bd777c056/pinecone_client-6.0.0.tar.gz", hash = "sha256:f224fc999205e4858c4737c40922bdf42d178b361c8859bc486ec00d45b359a9", size = 7004, upload-time = "2025-02-21T19:47:38.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/e4/7780cd631dc6dad0172a245e958b41b28a70779594c0790fa08b952aa97f/pinecone_client-6.0.0-py3-none-any.whl", hash = "sha256:d81a9e73cae441e4ab6dfc9c1d8b51c9895dae2488cda64f3e21b9dfc10c8d94", size = 6654, upload-time = "2025-02-21T19:47:37.23Z" }, -] - -[[package]] -name = "pinecone-datasets" -version = "0.6.1" -source = { git = "https://github.com/mrT23/pinecone-datasets.git?rev=main#9018d935d27e9bcc2b8de4c151228c60c6cc10da" } -dependencies = [ - { name = "fsspec", version = "2025.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "fsspec", version = "2026.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "gcsfs", version = "2025.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "gcsfs", version = "2026.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "pandas" }, - { name = "pinecone-client" }, - { name = "pyarrow" }, - { name = "pydantic" }, - { name = "s3fs" }, - { name = "tqdm" }, + { name = "anyio" }, + { name = "httpx", extra = ["http2"] }, + { name = "msgspec" }, + { name = "orjson" }, ] - -[[package]] -name = "pinecone-plugin-interface" -version = "0.0.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/fb/e8a4063264953ead9e2b24d9b390152c60f042c951c47f4592e9996e57ff/pinecone_plugin_interface-0.0.7.tar.gz", hash = "sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846", size = 3370, upload-time = "2024-06-05T01:57:52.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/97/f04b682e5d65d4cd09b76afd691f8834cf2c28d82bc942ad637a2c15c74d/pinecone-10.0.0.tar.gz", hash = "sha256:6066bbe9a7ae1d667cde08d262deb6fbea6feb35deb9177dd47141b55bbd9833", size = 525129, upload-time = "2026-09-03T14:31:22.729Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/1d/a21fdfcd6d022cb64cef5c2a29ee6691c6c103c4566b41646b080b7536a5/pinecone_plugin_interface-0.0.7-py3-none-any.whl", hash = "sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8", size = 6249, upload-time = "2024-06-05T01:57:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c7/9d61f9a4adaaa6e315a0425c2127b449bda64633a9c50bc533ff71c0c894/pinecone-10.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:995c06e905940b10bb2aefe653225340b5a3f56f3efb373fe07f4e57b5043705", size = 3098155, upload-time = "2026-09-03T14:31:11.281Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/d73c0aa1030931a1b28151c4ff727b793e443a4531521bc89a1d4c3124a0/pinecone-10.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0994270c514b16c72ec94dd6c29ff2708b81d30ff8467e19de192a28a7c86b7e", size = 3070497, upload-time = "2026-09-03T14:31:12.998Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/3b9dba42741ed8faf011d7bce8e1fd10f45dd0503e2888eceafa6be33424/pinecone-10.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d482ed27a805cbd4aca2660da212008dd6e41d87d255279f405ff13b725970e2", size = 3353834, upload-time = "2026-09-03T14:31:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/50e23cac1d73c921b8cc2eb02c9ad2691f62e8b906db49237bcf85f90676/pinecone-10.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94d4c64779f3213a5cc538d3bd10a873da192cb9b0039db56690e556ba00b55c", size = 3291380, upload-time = "2026-09-03T14:31:16.052Z" }, + { url = "https://files.pythonhosted.org/packages/04/ef/705e49608eac5803b9b55b6cfae2cac736c0395e709f7668f52c89afc99b/pinecone-10.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0e05956a3201b1fbb54a1861277df919318a4941797f2d87fd558ac5ec232151", size = 3528743, upload-time = "2026-09-03T14:31:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a4/11795c174225b9dea5054618c91b956d0a26b3d7167a5d3531e167712e83/pinecone-10.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3ab0c843b4fb04fbac22f1b8455e389063208a50f6a95d7a90627939968198de", size = 3574713, upload-time = "2026-09-03T14:31:19.198Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5d/93d123cfcf458ccc2defa4c0363be5fb7aabfcd375cd2414a3e944e7f55a/pinecone-10.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:2f4e3200ee3562d195802b363487dd7fe6039a8c13630fc25fa3e8726c7a8654", size = 2924160, upload-time = "2026-09-03T14:31:21.107Z" }, ] [[package]] @@ -2596,8 +2534,7 @@ dev = [ similar-issue = [ { name = "lancedb" }, { name = "pandas" }, - { name = "pinecone-client" }, - { name = "pinecone-datasets" }, + { name = "pinecone" }, { name = "qdrant-client" }, ] @@ -2664,8 +2601,7 @@ dev = [ similar-issue = [ { name = "lancedb", specifier = "==0.38.0" }, { name = "pandas" }, - { name = "pinecone-client" }, - { name = "pinecone-datasets", git = "https://github.com/mrT23/pinecone-datasets.git?rev=main" }, + { name = "pinecone", specifier = "==10.0.0" }, { name = "qdrant-client", specifier = "==1.15.1" }, ] @@ -3497,20 +3433,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] -[[package]] -name = "s3fs" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "fsspec", version = "2025.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "fsspec", version = "2026.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/504cb277632c4d325beabbd03bb43778f0decb9be22d9e0e6c62f44540c7/s3fs-0.4.2.tar.gz", hash = "sha256:2ca5de8dc18ad7ad350c0bd01aef0406aa5d0fff78a561f0f710f9d9858abdd0", size = 57527, upload-time = "2020-03-31T15:24:26.388Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/e4/b8fc59248399d2482b39340ec9be4bb2493846ac23641b43115a7e5cd675/s3fs-0.4.2-py3-none-any.whl", hash = "sha256:91c1dfb45e5217bd441a7a560946fe865ced6225ff7eb0fb459fe6e601a95ed3", size = 19791, upload-time = "2020-03-31T15:24:24.952Z" }, -] - [[package]] name = "s3transfer" version = "0.19.2" From 7d642fb664c5b9a9490fdd6011a8c5d61e73b102 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:38 +0545 Subject: [PATCH 031/238] fix(claude): warn when thinking is enabled but no model in the chain matches (#3235) --- docs/docs/usage-guide/changing_a_model.md | 38 ++++++++++ .../algo/ai_handlers/litellm_ai_handler.py | 21 +++++- .../test_litellm_claude_adaptive_thinking.py | 75 +++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 289d4c78c2..1efef809a0 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -343,6 +343,39 @@ model_id = "your-application-inference-profile-arn" The `litellm.model_id` parameter applies only to classic `bedrock/` calls made through the `bedrock-runtime` APIs. It does not apply to `bedrock_mantle/`; for cost allocation with the Mantle Chat Completions and Responses APIs, use [Amazon Bedrock Projects](https://docs.aws.amazon.com/bedrock/latest/userguide/cost-mgmt-projects.html). +#### Claude 5 thinking with an application inference profile ARN + +Claude Sonnet 5 on Bedrock is invoked through an inference profile rather than a direct +foundation-model id. When that profile is an application inference profile, its ARN is an +opaque value that carries no model name. Thinking configuration in PR-Agent is gated on +recognizing the model, so the opaque ARN can never match: `enable_claude_adaptive_thinking` +requires a recognized Claude 5 model name in the id, and `enable_claude_extended_thinking` +requires exact membership in `claude_extended_thinking_models`. PR-Agent logs a warning in +that case, so the unconfigured state is no longer silent. + +Address the model by name and pass the profile ARN through `litellm.model_id`, which is what +the invocation actually uses: + +```toml +[config] # in configuration.toml +model = "bedrock/converse/eu.anthropic.claude-sonnet-5" +fallback_models = ["bedrock/converse/eu.anthropic.claude-sonnet-5"] +enable_claude_adaptive_thinking = true # requires a recognizable claude 5 model name in `model` + +[litellm] +model_id = "arn:aws:bedrock:eu-central-1::application-inference-profile/" +``` + +Cost attribution is preserved through the application inference profile, and because `model` +is the named id, the adaptive-thinking payload is applied and kept intact. + +Two caveats. First, ARNs only fail the detection when the suffix is opaque: an ARN that +embeds the model family, for example `...:inference-profile/us.anthropic.claude-sonnet-5`, +normalises to a string the adaptive regex does match. The miss is specific to application +inference profiles with an opaque hex suffix. Second, `litellm.model_id` is a single global +value applied to every model whose id contains `bedrock/`, so this configuration cannot point +different models at different profiles within one fallback chain without per-call handling. + #### Using a Custom VPC Endpoint (PrivateLink) To route Bedrock traffic through a VPC interface endpoint instead of the public `bedrock-runtime` endpoint, set `AWS_BEDROCK_RUNTIME_ENDPOINT` either as an environment variable or in `[aws]`: @@ -693,6 +726,11 @@ built-in defaults. for it and logs a warning rather than sending a request the provider would reject — use `enable_claude_adaptive_thinking` for those models instead. +Both thinking gates only fire when the model id itself is recognizable: an opaque id such as a +Bedrock application inference profile ARN matches neither gate, and PR-Agent logs a warning +instead of silently sending nothing. See [Claude 5 thinking with an application inference +profile ARN](#claude-5-thinking-with-an-application-inference-profile-arn). + ## Output token limit ```toml diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index f41aacb2ed..70b2522385 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -946,12 +946,15 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: kwargs["allowed_openai_params"] = ["reasoning_effort"] # https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking - if self._is_claude_adaptive_thinking_model(model) and get_settings().config.get( - "enable_claude_adaptive_thinking", False): + adaptive_thinking_enabled = get_settings().config.get( + "enable_claude_adaptive_thinking", False) + extended_thinking_enabled = get_settings().config.get( + "enable_claude_extended_thinking", False) + if self._is_claude_adaptive_thinking_model(model) and adaptive_thinking_enabled: kwargs = self._configure_claude_adaptive_thinking(model, kwargs) elif ( model in self.claude_extended_thinking_models - and get_settings().config.get("enable_claude_extended_thinking", False) + and extended_thinking_enabled ): if self._is_claude_adaptive_thinking_model(model): get_logger().warning( @@ -960,6 +963,18 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: ) else: kwargs = self._configure_claude_extended_thinking(model, kwargs) + elif adaptive_thinking_enabled or extended_thinking_enabled: + message = ( + f"No thinking configuration applied for model {model}: adaptive thinking " + f"requires a recognized claude 5 model name in the id and extended " + f"thinking requires exact membership in claude_extended_thinking_models." + ) + if "arn:aws:bedrock:" in model: + message += ( + " For a Bedrock inference profile, address the model by name and pass " + "the ARN with litellm.model_id." + ) + get_logger().warning(message) # Optional output token limit; 0 = unset. Without max_tokens some # providers apply a low service-side default (Bedrock Converse: 4096, diff --git a/tests/unittest/test_litellm_claude_adaptive_thinking.py b/tests/unittest/test_litellm_claude_adaptive_thinking.py index ea2b92d4e0..5aa68e7bde 100644 --- a/tests/unittest/test_litellm_claude_adaptive_thinking.py +++ b/tests/unittest/test_litellm_claude_adaptive_thinking.py @@ -207,3 +207,78 @@ async def test_non_adaptive_model_in_extended_override_still_gets_extended_think assert kwargs["thinking"]["type"] == "enabled" assert "budget_tokens" in kwargs["thinking"] + + +@pytest.mark.asyncio +async def test_opaque_arn_with_adaptive_enabled_warns_and_skips_payload(monkeypatch): + """An opaque application inference profile ARN carries no model name, so the adaptive + payload must be skipped and a warning logged instead of failing silently (see #3216).""" + logger = MagicMock() + with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.get_logger", + return_value=logger): + kwargs = await _run_completion( + monkeypatch, + "bedrock/converse/arn:aws:bedrock:eu-central-1:000000000000:application-inference-profile/abc123def456", + enabled=True, + ) + + assert "thinking" not in kwargs + assert "output_config" not in kwargs + logger.warning.assert_called_once() + assert "abc123def456" in logger.warning.call_args.args[0] + assert "litellm.model_id" in logger.warning.call_args.args[0] + + +@pytest.mark.asyncio +async def test_family_embedding_arn_still_gets_adaptive_payload(monkeypatch): + """An ARN that embeds the model family normalises to a matching id, so adaptive thinking + applies; the miss is specific to opaque suffixes (see #3216).""" + kwargs = await _run_completion( + monkeypatch, + "bedrock/converse/arn:aws:bedrock:eu-central-1:000000000000:inference-profile/us.anthropic.claude-sonnet-5", + enabled=True, + ) + + assert kwargs["thinking"] == {"type": "adaptive"} + assert kwargs["output_config"] == {"effort": "medium"} + + +@pytest.mark.asyncio +async def test_extended_enabled_with_no_matching_model_warns(monkeypatch): + """Extended thinking enabled with an empty override list leaves no matching model, so the + warning fires even though the adaptive model gate is not involved (see #3216).""" + logger = MagicMock() + with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.get_logger", + return_value=logger): + kwargs = await _run_completion( + monkeypatch, + "anthropic/claude-opus-4-6", + enabled=False, + extended_enabled=True, + extended_override=[], + ) + + assert "thinking" not in kwargs + logger.warning.assert_called_once() + assert "opus-4-6" in logger.warning.call_args.args[0] + + +@pytest.mark.asyncio +async def test_non_arn_model_warns_without_bedrock_advice(monkeypatch): + """The Bedrock remedy must stay gated to Bedrock ids: a plain provider id that reaches the + warning branch only gets the generic message, never the litellm.model_id ARN advice.""" + logger = MagicMock() + with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.get_logger", + return_value=logger): + kwargs = await _run_completion( + monkeypatch, + "openai/gpt-4o", + enabled=True, + ) + + assert "thinking" not in kwargs + logger.warning.assert_called_once() + message = logger.warning.call_args.args[0] + assert "gpt-4o" in message + assert "litellm.model_id" not in message + assert "arn" not in message From 6324188c6f582a11c59a13605f6c0af36e20ebb3 Mon Sep 17 00:00:00 2001 From: Aurora Date: Thu, 10 Sep 2026 12:17:32 +0200 Subject: [PATCH 032/238] fix(tickets): match hyphens and periods in cross-repo issue references (#3264) Co-authored-by: auroraxo Co-authored-by: Aurora --- pr_agent/tools/ticket_pr_compliance_check.py | 4 +- .../test_github_issue_reference_extraction.py | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 38e25d7b1e..a530656758 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -15,7 +15,9 @@ # Compile the regex pattern once, outside the function GITHUB_TICKET_PATTERN = re.compile( - r'(https://github[^/]+/[^/]+/[^/]+/issues/\d+)|(\b(\w+)/(\w+)#(\d+)\b)|(#\d+)' + r'(https://github[^/]+/[^/]+/[^/]+/issues/\d+)' + r'|((? Date: Thu, 10 Sep 2026 05:54:45 -0500 Subject: [PATCH 033/238] fix(help): try fallback models after completion failures (#3266) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/tools/pr_help_message.py | 24 +-- .../unittest/test_pr_help_message_fallback.py | 158 ++++++++++++++++++ 2 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 tests/unittest/test_pr_help_message_fallback.py diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index d105d213d5..39dde65771 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -3,7 +3,7 @@ from functools import partial from pathlib import Path -from jinja2 import Environment, StrictUndefined +from jinja2 import Environment, StrictUndefined, select_autoescape from pr_agent.algo import MAX_TOKENS from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler @@ -34,17 +34,17 @@ def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = get_settings().pr_help_prompts.user) async def _prepare_prediction(self, model: str): - try: - variables = copy.deepcopy(self.vars) - environment = Environment(undefined=StrictUndefined) - system_prompt = environment.from_string(get_settings().pr_help_prompts.system).render(variables) - user_prompt = environment.from_string(get_settings().pr_help_prompts.user).render(variables) - response, finish_reason = await self.ai_handler.chat_completion( - model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) - return response - except Exception as e: - get_logger().error(f"Error while preparing prediction: {e}") - return "" + variables = copy.deepcopy(self.vars) + # These string templates produce plain-text model prompts, not HTML. + environment = Environment( + autoescape=select_autoescape(default_for_string=False), + undefined=StrictUndefined, + ) + system_prompt = environment.from_string(get_settings().pr_help_prompts.system).render(variables) + user_prompt = environment.from_string(get_settings().pr_help_prompts.user).render(variables) + response, finish_reason = await self.ai_handler.chat_completion( + model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) + return response def parse_args(self, args): if args and len(args) > 0: diff --git a/tests/unittest/test_pr_help_message_fallback.py b/tests/unittest/test_pr_help_message_fallback.py new file mode 100644 index 0000000000..01877804cb --- /dev/null +++ b/tests/unittest/test_pr_help_message_fallback.py @@ -0,0 +1,158 @@ +"""Question-mode help delegates failed attempts to the shared fallback loop.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from pr_agent.algo import run_details +from pr_agent.config_loader import get_settings +from pr_agent.tools import pr_help_message +from pr_agent.tools.pr_help_message import PRHelpMessage +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +PRIMARY = "gpt-4o" +BACKUP = "gpt-4o-mini" +ANSWER = ( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" +) + + +@pytest.fixture +def help_tool(tmp_path, monkeypatch): + overrides = { + "config.model": PRIMARY, + "config.fallback_models": [BACKUP], + "config.publish_output": True, + "model_routing.enable": False, + # Keep this regression independent of the separate OpenAI-key gate fix. + "openai.key": "test-only-key", + "openai.deployment_id": "primary-deployment", + "openai.fallback_deployments": ["backup-deployment"], + } + snapshot = snapshot_settings([*overrides, "pr_help_prompts.user"]) + for key, value in overrides.items(): + get_settings().set(key, value) + + doc = tmp_path / "docs" / "docs" / "tools" / "review.md" + doc.parent.mkdir(parents=True) + doc.write_text("# Automatic review\n\nEnable automatic review in the repository settings.", encoding="utf-8") + monkeypatch.setattr(pr_help_message, "__file__", str(tmp_path / "pr_agent" / "tools" / "pr_help_message.py")) + + details = run_details.RunDetails() + monkeypatch.setattr(run_details, "get_run_details", lambda: details) + logger = Mock() + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace(pr_url="https://example.com/org/repo/pull/1", publish_comment=Mock()) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How do I configure automatic reviews?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + tool.token_handler = SimpleNamespace(count_tokens=len) + try: + yield tool, details, logger + finally: + restore_settings(snapshot) + + +def attempted_models(tool): + return [call.kwargs["model"] for call in tool.ai_handler.chat_completion.await_args_list] + + +async def test_model_prompt_preserves_question_and_documentation_markup(help_tool): + tool, _, _ = help_tool + question = "What does ` & 'notes'` mean?" + snippets = "Use `a < b && c > d` and ``." + tool.vars.update(question=question, snippets=snippets) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + assert await tool._prepare_prediction(PRIMARY) == ANSWER + + tool.ai_handler.chat_completion.assert_awaited_once() + user_prompt = tool.ai_handler.chat_completion.await_args.kwargs["user"] + assert question in user_prompt + assert snippets in user_prompt + + +async def test_primary_failure_uses_backup_answer(help_tool): + tool, details, _ = help_tool + tool.ai_handler.chat_completion.side_effect = [RuntimeError("primary unavailable"), (ANSWER, "stop")] + + await tool.run() + + assert attempted_models(tool) == [PRIMARY, BACKUP] + backup_prompt = tool.ai_handler.chat_completion.await_args_list[1].kwargs["user"] + assert tool.question_str in backup_prompt + assert "Enable automatic review in the repository settings." in backup_prompt + tool.git_provider.publish_comment.assert_called_once() + assert "### Answer:\nEnable automatic review" in tool.git_provider.publish_comment.call_args.args[0] + assert details.model_used == BACKUP + assert details.fallback_used is True + assert get_settings().get("openai.deployment_id") == "primary-deployment" + + +async def test_all_models_fail_without_publishing_no_information(help_tool): + tool, details, logger = help_tool + tool.ai_handler.chat_completion.side_effect = RuntimeError("provider unavailable") + + assert await tool.run() == "" + + assert attempted_models(tool) == [PRIMARY, BACKUP] + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_called_once() + assert "Failed to generate prediction with any model" in logger.exception.call_args.args[0] + assert details.model_used is None + assert get_settings().get("openai.deployment_id") == "primary-deployment" + + +@pytest.mark.parametrize( + "response, expected", + [(ANSWER, "### Answer:\nEnable automatic review"), + ("response: No matching information\nrelevant_sections: []", "Could not find relevant information")], +) +async def test_successful_primary_response_does_not_use_backup(help_tool, response, expected): + tool, details, _ = help_tool + tool.ai_handler.chat_completion.return_value = response, "stop" + + await tool.run() + + assert attempted_models(tool) == [PRIMARY] + tool.git_provider.publish_comment.assert_called_once() + assert expected in tool.git_provider.publish_comment.call_args.args[0] + assert details.model_used == PRIMARY + assert details.fallback_used is False + + +async def test_cancellation_propagates_without_backup(help_tool): + tool, details, logger = help_tool + tool.ai_handler.chat_completion.side_effect = asyncio.CancelledError() + + with pytest.raises(asyncio.CancelledError): + await tool.run() + + assert attempted_models(tool) == [PRIMARY] + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_not_called() + assert details.model_used is None + assert get_settings().get("openai.deployment_id") == "primary-deployment" + + +async def test_invalid_prompt_reaches_final_failure_boundary(help_tool): + tool, details, logger = help_tool + get_settings().set("pr_help_prompts.user", "{{ missing_variable }}") + tool._prepare_prediction = AsyncMock(wraps=tool._prepare_prediction) + + assert await tool.run() == "" + + assert [call.args[0] for call in tool._prepare_prediction.await_args_list] == [PRIMARY, BACKUP] + tool.ai_handler.chat_completion.assert_not_called() + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_called_once() + assert "Failed to generate prediction with any model" in logger.exception.call_args.args[0] + assert details.model_used is None + assert get_settings().get("openai.deployment_id") == "primary-deployment" From e705c10300c5310012726d1726dd5cc2614057c3 Mon Sep 17 00:00:00 2001 From: Aurora Date: Thu, 10 Sep 2026 13:41:40 +0200 Subject: [PATCH 034/238] refactor(similar_issue): select issue indexing by provider capability (#3253) Co-authored-by: auroraxo --- pr_agent/git_providers/git_provider.py | 12 ++ pr_agent/git_providers/github_provider.py | 4 + pr_agent/tools/pr_similar_issue.py | 19 ++- .../unittest/test_similar_issue_non_github.py | 49 ------- .../test_similar_issue_provider_capability.py | 134 ++++++++++++++++++ 5 files changed, 167 insertions(+), 51 deletions(-) delete mode 100644 tests/unittest/test_similar_issue_non_github.py create mode 100644 tests/unittest/test_similar_issue_provider_capability.py diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 0b7d5f4060..b272272529 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -194,6 +194,18 @@ def supports_pr_chat(self) -> bool: """Whether this provider is compatible with the linked PR-Agent browser-extension chat experience.""" return False + @classmethod + def supports_issue_indexing(cls) -> bool: + """Whether `/similar_issue` can read and index this provider's issues. + + Declared on the class rather than on an instance because the tool consults it before + constructing a provider: `PRSimilarIssue` needs to know whether to build one at all. + The indexing path relies on issue listing, issue bodies and issue comments, so a + provider that exposes those overrides this; the default is no support, so the tool + reports the command as unsupported instead of failing part-way through. + """ + return False + def supports_markdown_tables(self) -> bool: """Whether comments render pipe-table markdown. diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index b5a1758efc..c82afc92b7 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -131,6 +131,10 @@ def supports_checkbox_commands(self) -> bool: def supports_pr_chat(self) -> bool: return True + @classmethod + def supports_issue_indexing(cls) -> bool: + return True + def _get_owner_and_repo_path(self, given_url: str) -> str: try: repo_path = None diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index e7c26e6504..1b8629c63d 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -67,10 +67,25 @@ def _qdrant_collection_name(base_name: str) -> str: return f"{base_name}-v2" +def _provider_supports_issue_indexing() -> bool: + """Whether the configured provider can back `/similar_issue`. + + The check is on the provider class rather than on the configured id, so a provider + registered through `register_git_provider()` is judged by the capability it declares. + An unresolvable configuration is reported as unsupported, which is what the tool's + `run()` already handles, rather than raised out of `__init__`. + """ + try: + provider_class = get_git_provider() + except ValueError: + return False + return provider_class.supports_issue_indexing() + + class PRSimilarIssue: def __init__(self, issue_url: str, ai_handler, args: list = None): self.issue_url = issue_url - self.supported = get_settings().config.git_provider == "github" + self.supported = _provider_supports_issue_indexing() if not self.supported: return @@ -307,7 +322,7 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): async def run(self): if not self.supported: - message = "The /similar_issue tool is currently supported only for GitHub." + message = "The /similar_issue tool is not supported by the configured git provider." if get_settings().config.publish_output: try: from pr_agent.git_providers import get_git_provider_with_context diff --git a/tests/unittest/test_similar_issue_non_github.py b/tests/unittest/test_similar_issue_non_github.py deleted file mode 100644 index 6581448692..0000000000 --- a/tests/unittest/test_similar_issue_non_github.py +++ /dev/null @@ -1,49 +0,0 @@ -import pytest - -from pr_agent.tools.pr_similar_issue import PRSimilarIssue - - -@pytest.mark.asyncio -async def test_similar_issue_non_github_publishes_message(monkeypatch): - class FakeProvider: - def __init__(self): - self.comments = [] - - def publish_comment(self, body): - self.comments.append(body) - - fake_provider = FakeProvider() - - class FakeSettings: - class config: - git_provider = "gitlab" - publish_output = True - - monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) - monkeypatch.setattr( - "pr_agent.git_providers.get_git_provider_with_context", - lambda _: fake_provider, - ) - - tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) - result = await tool.run() - - assert result == "" - assert fake_provider.comments == [ - "The /similar_issue tool is currently supported only for GitHub." - ] - - -@pytest.mark.asyncio -async def test_similar_issue_non_github_no_publish(monkeypatch): - class FakeSettings: - class config: - git_provider = "gitlab" - publish_output = False - - monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) - - tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) - result = await tool.run() - - assert result == "" diff --git a/tests/unittest/test_similar_issue_provider_capability.py b/tests/unittest/test_similar_issue_provider_capability.py new file mode 100644 index 0000000000..db50b52aa1 --- /dev/null +++ b/tests/unittest/test_similar_issue_provider_capability.py @@ -0,0 +1,134 @@ +from types import SimpleNamespace + +import pytest + +from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.tools.pr_similar_issue import PRSimilarIssue, _provider_supports_issue_indexing + + +class UnsupportedProvider(GitProvider): + """A provider class that does not declare the issue-indexing capability.""" + + def is_supported(self, capability: str) -> bool: + return False + + +@pytest.mark.asyncio +async def test_similar_issue_unsupported_provider_publishes_message(monkeypatch): + class FakeProvider: + def __init__(self): + self.comments = [] + + def publish_comment(self, body): + self.comments.append(body) + + fake_provider = FakeProvider() + + class FakeSettings: + class config: + git_provider = "gitlab" + publish_output = True + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: UnsupportedProvider) + monkeypatch.setattr( + "pr_agent.git_providers.get_git_provider_with_context", + lambda _: fake_provider, + ) + + tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) + result = await tool.run() + + assert result == "" + assert fake_provider.comments == [ + "The /similar_issue tool is not supported by the configured git provider." + ] + + +@pytest.mark.asyncio +async def test_similar_issue_unsupported_provider_no_publish(monkeypatch): + class FakeSettings: + class config: + git_provider = "gitlab" + publish_output = False + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: UnsupportedProvider) + + tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) + result = await tool.run() + + assert result == "" + + +def test_github_provider_declares_issue_indexing(): + """The capability replaces the previous `git_provider == "github"` string comparison.""" + assert GithubProvider.supports_issue_indexing() is True + + +def test_git_provider_default_is_unsupported(): + """A provider that does not override the capability is treated as unsupported.""" + assert GitProvider.supports_issue_indexing() is False + assert UnsupportedProvider.supports_issue_indexing() is False + + +def test_capability_is_read_off_the_registered_class(monkeypatch): + """A provider registered from outside the package is judged by what it declares.""" + + class ThirdPartyProvider(GitProvider): + def is_supported(self, capability: str) -> bool: + return False + + @classmethod + def supports_issue_indexing(cls) -> bool: + return True + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: ThirdPartyProvider) + assert _provider_supports_issue_indexing() is True + + +def test_declared_capability_beats_the_configured_provider_id(monkeypatch): + """A non-`github` id that declares the capability is supported. + + This is the behavior change: the previous `config.git_provider == "github"` comparison + turned any other id away regardless of what the provider could do. The provider is built and + interrupted at its GitHub client hook, so the test observes that the guard let it through + without running the indexing path. + """ + + class Sentinel(Exception): + pass + + class CapableProvider(GithubProvider): + def _get_github_client(self): + raise Sentinel + + @classmethod + def supports_issue_indexing(cls) -> bool: + return True + + class FakeSettings: + class config: + git_provider = "my-forge" + publish_output = False + CLI_MODE = True + + CONFIG = config + pr_similar_issue = SimpleNamespace(max_issues_to_scan=10, vectordb="pinecone") + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: CapableProvider) + + with pytest.raises(Sentinel): + PRSimilarIssue("https://my-forge.example.com/group/repo/issues/1", None) + + +def test_unknown_provider_configuration_is_unsupported(monkeypatch): + """An unresolvable provider id is reported as unsupported rather than raised.""" + + def raise_unknown(): + raise ValueError("Unknown git provider: nope") + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", raise_unknown) + assert _provider_supports_issue_indexing() is False From f196f21349ace952af0abfa0cd5c2b833b9d2949 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:42:39 +0545 Subject: [PATCH 035/238] chore: burn down the first five rules in the ruff lint.ignore ledger (#3207) Co-authored-by: Utsab --- pr_agent/algo/pr_processing.py | 2 +- pr_agent/algo/token_handler.py | 4 +++- pr_agent/algo/utils.py | 12 ++++++++---- pr_agent/git_providers/github_provider.py | 4 ++-- pr_agent/servers/azuredevops_server_webhook.py | 3 +-- pr_agent/servers/bitbucket_app.py | 2 +- pr_agent/servers/gerrit_server.py | 2 +- pr_agent/tools/pr_code_suggestions.py | 2 +- pr_agent/tools/pr_reviewer.py | 2 +- pyproject.toml | 7 +------ 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index a0d041b1b3..68943aee66 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -264,7 +264,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo # additional iterations (if needed) if large_pr_handling: NUMBER_OF_ALLOWED_ITERATIONS = get_settings().pr_description.get("max_ai_calls", 4) - 1 # one more call is to summarize - for i in range(NUMBER_OF_ALLOWED_ITERATIONS-1): + for _ in range(NUMBER_OF_ALLOWED_ITERATIONS-1): if remaining_files_list: total_tokens, patches, remaining_files_list, files_in_patch_list = generate_full_patch(convert_hunks_to_line_numbers, file_dict, diff --git a/pr_agent/algo/token_handler.py b/pr_agent/algo/token_handler.py index 135e132121..6faa2af88e 100644 --- a/pr_agent/algo/token_handler.py +++ b/pr_agent/algo/token_handler.py @@ -66,7 +66,7 @@ class TokenHandler: CLAUDE_MODEL = "claude-3-7-sonnet-20250219" CLAUDE_MAX_CONTENT_SIZE = 9_000_000 # Maximum allowed content size (9MB) for Claude API - def __init__(self, pr=None, vars: dict = {}, system="", user="", model=None): + def __init__(self, pr=None, vars: dict | None = None, system="", user="", model=None): """ Initializes the TokenHandler object. @@ -77,6 +77,8 @@ def __init__(self, pr=None, vars: dict = {}, system="", user="", model=None): - user: The user string. - model: Optional model name whose tokenizer should be used. """ + if vars is None: + vars = {} self.encoder = TokenEncoder.get_token_encoder(model) if pr is not None: diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index c74e144868..ca9b02ca25 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -478,7 +478,7 @@ def convert_to_markdown_v2(output_data: dict, markdown_text += f"{emoji} Recommended focus areas for review

\n\n" else: markdown_text += f"### {emoji} Recommended focus areas for review\n\n#### \n" - for i, issue in enumerate(issues): + for issue in issues: try: if not issue or not isinstance(issue, dict): continue @@ -681,7 +681,7 @@ def process_can_be_split(emoji, value): markdown_text += f"{emoji} No multiple PR themes\n\n" else: markdown_text += f"{emoji} {key_nice}

\n\n" - for i, split in enumerate(value): + for split in value: title = split.get('title', '') relevant_files = split.get('relevant_files', []) markdown_text += f"
\nSub-PR theme: {title}\n\n" @@ -983,7 +983,9 @@ def sanitize_yaml_control_chars(text: str, log: bool = True) -> str: return sanitized -def load_yaml(response_text: str, keys_fix_yaml: List[str] = [], first_key="", last_key="") -> dict: +def load_yaml(response_text: str, keys_fix_yaml: List[str] | None = None, first_key="", last_key="") -> dict: + if keys_fix_yaml is None: + keys_fix_yaml = [] response_text_original = copy.deepcopy(response_text) response_text = response_text.strip('\n') # strip the fence label only when it is a complete info string, so a key such as @@ -1022,10 +1024,12 @@ def load_yaml(response_text: str, keys_fix_yaml: List[str] = [], first_key="", l def try_fix_yaml(response_text: str, - keys_fix_yaml: List[str] = [], + keys_fix_yaml: List[str] | None = None, first_key="", last_key="", response_text_original="") -> dict: + if keys_fix_yaml is None: + keys_fix_yaml = [] response_text_lines = response_text.split('\n') keys_yaml = ['relevant line:', 'suggestion content:', 'relevant file:', 'existing code:', diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index c82afc92b7..6bb1ec74dc 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1710,7 +1710,7 @@ def validate_comments_inside_hunks(self, code_suggestions): if not hasattr(file, 'patches_range'): file.patches_range = [] patch_lines = patch_str.splitlines() - for i, line in enumerate(patch_lines): + for line in patch_lines: if line.startswith('@@'): match = RE_HUNK_HEADER.match(line) # identify hunk header @@ -1730,7 +1730,7 @@ def validate_comments_inside_hunks(self, code_suggestions): min_distance = float('inf') patch_range_min = None # find the hunk that contains the comment, or the closest one - for i, patch_range in enumerate(patches_range): + for patch_range in patches_range: d1 = comment_start_line - patch_range['start'] d2 = patch_range['end'] - comment_end_line if d1 >= 0 and d2 >= 0: # found a valid hunk diff --git a/pr_agent/servers/azuredevops_server_webhook.py b/pr_agent/servers/azuredevops_server_webhook.py index 3e0eaac012..0358a25c90 100644 --- a/pr_agent/servers/azuredevops_server_webhook.py +++ b/pr_agent/servers/azuredevops_server_webhook.py @@ -15,7 +15,6 @@ from starlette import status from starlette.background import BackgroundTasks from starlette.middleware import Middleware -from starlette.requests import Request from starlette.responses import JSONResponse from starlette_context import context from starlette_context.middleware import RawContextMiddleware @@ -180,7 +179,7 @@ def handle_line_comment(body: str, thread_id: int, comment_id: int, provider: Az # currently only basic auth is supported with azure webhooks # for this reason, https must be enabled to ensure the credentials are not sent in clear text -def authorize(credentials: HTTPBasicCredentials = Depends(security)): +def authorize(credentials: HTTPBasicCredentials = Depends(security)): # noqa: B008 if not WEBHOOK_USERNAME and not WEBHOOK_PASSWORD: return if not WEBHOOK_USERNAME or not WEBHOOK_PASSWORD: diff --git a/pr_agent/servers/bitbucket_app.py b/pr_agent/servers/bitbucket_app.py index 8f28fea7e2..e4bb0e49a5 100644 --- a/pr_agent/servers/bitbucket_app.py +++ b/pr_agent/servers/bitbucket_app.py @@ -409,7 +409,7 @@ async def inner(): return "OK" @router.get("/webhook") -async def handle_github_webhooks(request: Request, response: Response): +async def handle_webhook_health(request: Request, response: Response): return "Webhook server online!" @router.post("/installed") diff --git a/pr_agent/servers/gerrit_server.py b/pr_agent/servers/gerrit_server.py index b136570eb5..9dbcfe7dad 100644 --- a/pr_agent/servers/gerrit_server.py +++ b/pr_agent/servers/gerrit_server.py @@ -21,7 +21,7 @@ security = HTTPBasic(auto_error=False) -def authorize(credentials: HTTPBasicCredentials = Depends(security)): +def authorize(credentials: HTTPBasicCredentials = Depends(security)): # noqa: B008 """Require the configured webhook credentials on every call.""" gerrit_settings = get_settings().get("gerrit", {}) username = gerrit_settings.get("webhook_username", None) if gerrit_settings else None diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index af971fde92..2a65252f5c 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -825,7 +825,7 @@ async def _get_prediction(self, model: str, patches_diff: str, patches_diff_no_l await self.analyze_self_reflection_response(data, response_reflect) else: # get_logger().error(f"Could not self-reflect on suggestions. using default score 7") - for i, suggestion in enumerate(data["code_suggestions"]): + for suggestion in data["code_suggestions"]: suggestion["score"] = 7 suggestion["score_why"] = "" diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 3a365fd097..d75bfaef64 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -1010,7 +1010,7 @@ def _prepare_pr_review(self) -> str: # Add custom labels from the review prediction (effort, security) self.set_review_labels(data) - if markdown_text == None or len(markdown_text) == 0: + if markdown_text is None or len(markdown_text) == 0: markdown_text = "" return markdown_text diff --git a/pyproject.toml b/pyproject.toml index c546e48dbb..2660d22f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,19 +162,14 @@ lint.unfixable = [ lint.exclude = ["api/code_completions"] -# B008 is a long-standing ignore. Everything below it is a pre-existing +# Everything below is a pre-existing # violation deferred so ruff runs green on adoption — fix the code and drop # the entry. TODO: burn this list down. lint.ignore = [ - "B008", # function call in argument defaults "E402", # import not at top of file "E501", # line too long - "E711", # comparison to None "E722", # bare `except` - "F811", # redefinition of unused name "F841", # unused local variable - "B006", # mutable argument default - "B007", # unused loop control variable "B011", # `assert False` "B017", # assertRaises(Exception) "B023", # function definition uses loop variable From 554c5fa4847cc4898f78c5d96c9a42be9ee9a63a Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:46:23 +0545 Subject: [PATCH 036/238] docs: document the [push_outputs] feature (#3210) Co-authored-by: Utsab Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- docs/docs/summary.md | 1 + docs/docs/usage-guide/index.md | 1 + docs/docs/usage-guide/push_outputs.md | 90 +++++++++++++++++++++++++++ docs/mkdocs.yml | 1 + 4 files changed, 93 insertions(+) create mode 100644 docs/docs/usage-guide/push_outputs.md diff --git a/docs/docs/summary.md b/docs/docs/summary.md index babd4565ad..3e9acff231 100644 --- a/docs/docs/summary.md +++ b/docs/docs/summary.md @@ -23,6 +23,7 @@ * [Configuration Reference](usage-guide/configuration_reference.md) * [Usage and Automation](usage-guide/automations_and_usage.md) * [Managing Mail Notifications](usage-guide/mail_notifications.md) +* [Push Outputs](usage-guide/push_outputs.md) * [Changing a Model](usage-guide/changing_a_model.md) * [Extending PR-Agent](usage-guide/extending_pr_agent.md) * [Additional Configurations](usage-guide/additional_configurations.md) diff --git a/docs/docs/usage-guide/index.md b/docs/docs/usage-guide/index.md index af65146f83..3fa11eb02d 100644 --- a/docs/docs/usage-guide/index.md +++ b/docs/docs/usage-guide/index.md @@ -16,6 +16,7 @@ It includes information on how to adjust PR-Agent configurations, define which t - [BitBucket App](./automations_and_usage.md#bitbucket-app) - [Azure DevOps Provider](./automations_and_usage.md#azure-devops-provider) - [Managing Mail Notifications](./mail_notifications.md) +- [Push Outputs](./push_outputs.md) - [Changing a Model](./changing_a_model.md) - [Additional Configurations](./additional_configurations.md) - [Ignoring files from analysis](./additional_configurations.md#ignoring-files-from-analysis) diff --git a/docs/docs/usage-guide/push_outputs.md b/docs/docs/usage-guide/push_outputs.md new file mode 100644 index 0000000000..7000443d21 --- /dev/null +++ b/docs/docs/usage-guide/push_outputs.md @@ -0,0 +1,90 @@ +# Push outputs to external sinks + +The `[push_outputs]` feature routes finished tool output to external sinks — stdout, a JSONL file, a +generic webhook, or Slack — without calling git-provider APIs. It is disabled by default, and is +additive to normal publishing: when a tool finishes, the same result that is posted as a PR comment +is also emitted to the configured sinks. + +## What gets pushed + +Each finished tool run emits one record. The tools that currently emit are: + +| Tool | `type` in the record | +|---|---| +| `/review` | `review` | +| `/describe` | `describe` | +| `/improve` | `improve` | + +A record is a JSON object: + +```json +{ + "type": "review", + "timestamp": "2026-09-08T14:03:21+00:00", + "payload": {}, + "markdown": "# PR Reviewer Guide ..." +} +``` + +- `type` — which tool produced the output +- `timestamp` — the run completion time, UTC, ISO-8601 +- `payload` — the structured tool result +- `markdown` — the rendered comment text; present when the tool produces one + +## Configuration + +The defaults are defined at the end of the +[configuration file](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml): + +```toml +[push_outputs] +enable = false +channels = [] # any of: "stdout", "file", "webhook", "slack" +file_path = "pr-agent-outputs/reviews.jsonl" +webhook_url = "" # must be an absolute https:// URL +slack_webhook_url = "" # Slack Incoming Webhook; must be an absolute https:// URL +``` + +- `enable` — master switch (default `false`). When `false`, nothing is emitted. +- `channels` — which sinks to use. Nothing is emitted until at least one channel is listed here. +- `file_path` — the file the `file` channel appends to. +- `webhook_url` — the endpoint the `webhook` channel POSTs the generic record to. +- `slack_webhook_url` — a Slack Incoming Webhook URL that the `slack` channel posts a `{"text": ...}` payload to. + +!!! danger "Host-only configuration" + The whole `[push_outputs]` section is **host-only**. A repository cannot set these keys: + keys supplied through a repo's local `.pr_agent.toml` are dropped, and CLI arguments + (`--push_outputs.webhook_url=...`, `--push_outputs={...}`) are blocked. This prevents a + pull request from redirecting review output to an attacker-controlled host, reaching + internal endpoints, or appending to arbitrary host files. Configure these values in the + PR-Agent host's own settings. + +### URL requirements + +`webhook_url` and `slack_webhook_url` must be absolute `https://` URLs with a host. Any other +value (for example a plain `http://` URL or a bare path) is ignored with a warning. Requiring +HTTPS keeps review text, which can quote private code, off plaintext transports. The host is +intentionally not restricted, so self-hosted collectors and Slack-compatible endpoints +(Mattermost, Rocket.Chat) are legitimate targets. + +Warnings log the setting name — never the URL value — because a webhook or Slack URL is itself a +credential. + +## Channels + +| Channel | Behaviour | +|---|---| +| `stdout` | Prints one JSON line (the record) to stdout. | +| `file` | Appends one JSON line per run (JSONL) to `file_path`, creating parent directories as needed. | +| `webhook` | POSTs the generic record as JSON to `webhook_url` (5-second timeout, redirects not followed). | +| `slack` | POSTs `{"text": ...}` to a Slack Incoming Webhook; the text is the markdown, or the payload JSON when the tool produces no markdown. | + +Local channels (`stdout`, `file`) run before network channels (`webhook`, `slack`), and network +posts never follow redirects, so a failed or redirecting POST cannot lose an already-written file +line or be forwarded to a different host. + +## Error handling + +Failures are non-fatal: `push_outputs` never raises, so a sink outage does not break the review +flow. Errors are logged with the exception type only, since request error messages can embed the +(secret-bearing) URL. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ff87557569..66d386a373 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -23,6 +23,7 @@ nav: - Configuration Reference: 'usage-guide/configuration_reference.md' - Usage and Automation: 'usage-guide/automations_and_usage.md' - Managing Mail Notifications: 'usage-guide/mail_notifications.md' + - Push Outputs: 'usage-guide/push_outputs.md' - Changing a Model: 'usage-guide/changing_a_model.md' - Extending PR-Agent: 'usage-guide/extending_pr_agent.md' - Additional Configurations: 'usage-guide/additional_configurations.md' From b1a873719e545aabf9488db6c6314474b79ccb51 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Thu, 10 Sep 2026 07:22:13 -0500 Subject: [PATCH 037/238] fix(codecommit): update persistent review comments (#3158) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- docs/docs/installation/github.md | 1 + pr_agent/git_providers/codecommit_client.py | 70 ++- pr_agent/git_providers/codecommit_provider.py | 298 +++++++++- tests/unittest/test_codecommit_client.py | 60 ++ tests/unittest/test_codecommit_provider.py | 543 ++++++++++++++++++ .../test_git_provider_method_contract.py | 49 +- 6 files changed, 1000 insertions(+), 21 deletions(-) diff --git a/docs/docs/installation/github.md b/docs/docs/installation/github.md index 9e3da8a862..addb18b1d5 100644 --- a/docs/docs/installation/github.md +++ b/docs/docs/installation/github.md @@ -845,6 +845,7 @@ Example IAM permissions to that user to allow access to CodeCommit: "codecommit:List*", "codecommit:PostComment*", "codecommit:PutCommentReaction", + "codecommit:UpdateComment", "codecommit:UpdatePullRequestDescription", "codecommit:UpdatePullRequestTitle" ], diff --git a/pr_agent/git_providers/codecommit_client.py b/pr_agent/git_providers/codecommit_client.py index bca9a54bc9..f134acfcb8 100644 --- a/pr_agent/git_providers/codecommit_client.py +++ b/pr_agent/git_providers/codecommit_client.py @@ -54,6 +54,7 @@ class CodeCommitClient: def __init__(self): self.boto_client = None + self.comments_page_size = 100 def is_supported(self, capability: str) -> bool: if capability in ["gfm_markdown"]: @@ -235,7 +236,7 @@ def publish_comment(self, repo_name: str, pr_number: int, destination_commit: st It does not support the ending line number to highlight a range of lines. Returns: - - None + - The boto3 post_comment_for_pull_request response Boto3 Documentation: - aws codecommit post_comment_for_pull_request @@ -248,7 +249,7 @@ def publish_comment(self, repo_name: str, pr_number: int, destination_commit: st # If the comment has code annotations, # then set the file path and line number in the location dictionary if annotation_file and annotation_line: - self.boto_client.post_comment_for_pull_request( + return self.boto_client.post_comment_for_pull_request( pullRequestId=str(pr_number), repositoryName=repo_name, beforeCommitId=destination_commit, @@ -262,7 +263,7 @@ def publish_comment(self, repo_name: str, pr_number: int, destination_commit: st ) else: # The comment does not have code annotations - self.boto_client.post_comment_for_pull_request( + return self.boto_client.post_comment_for_pull_request( pullRequestId=str(pr_number), repositoryName=repo_name, beforeCommitId=destination_commit, @@ -277,3 +278,66 @@ def publish_comment(self, repo_name: str, pr_number: int, destination_commit: st raise ValueError("Boto3 client error calling post_comment_for_pull_request") from e except Exception as e: raise ValueError("Error calling post_comment_for_pull_request") from e + + def get_comments_for_pull_request(self, pr_number: int): + """ + Retrieve all comments for a pull request. + + Args: + - pr_number: The AWS CodeCommit pull request number + + Returns: + - The flattened commentsForPullRequestData entries from all result pages + + Boto3 Documentation: + - aws codecommit get_comments_for_pull_request + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/get_comments_for_pull_request.html + """ + if self.boto_client is None: + self._connect_boto_client() + + comments_for_pull_request = [] + try: + paginator = self.boto_client.get_paginator("get_comments_for_pull_request") + for page in paginator.paginate( + pullRequestId=str(pr_number), + PaginationConfig={"PageSize": self.comments_page_size}, + ): + comments_for_pull_request.extend(page.get("commentsForPullRequestData", [])) + except botocore.exceptions.ClientError as e: + if e.response["Error"]["Code"] == 'PullRequestDoesNotExistException': + raise ValueError(f"PR number does not exist: {pr_number}") from e + raise ValueError("Boto3 client error calling get_comments_for_pull_request") from e + except Exception as e: + raise ValueError("Error calling get_comments_for_pull_request") from e + + return comments_for_pull_request + + def update_comment(self, comment_id: str, comment: str): + """ + Update an existing pull request comment. + + Args: + - comment_id: The system-generated CodeCommit comment identifier + - comment: The replacement comment body + + Returns: + - The boto3 update_comment response + + Boto3 Documentation: + - aws codecommit update_comment + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/update_comment.html + """ + if self.boto_client is None: + self._connect_boto_client() + + try: + return self.boto_client.update_comment(commentId=str(comment_id), content=comment) + except botocore.exceptions.ClientError as e: + if e.response["Error"]["Code"] == 'CommentDoesNotExistException': + raise ValueError(f"CodeCommit comment does not exist: {comment_id}") from e + if e.response["Error"]["Code"] == 'CommentDeletedException': + raise ValueError(f"CodeCommit comment has been deleted: {comment_id}") from e + raise ValueError("Boto3 client error calling update_comment") from e + except Exception as e: + raise ValueError("Error calling update_comment") from e diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index 40112dd0d5..acc9c58d9d 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -1,6 +1,8 @@ import os import re from collections import Counter +from datetime import datetime +from types import SimpleNamespace from typing import List, Optional, Tuple from urllib.parse import urlparse @@ -8,7 +10,12 @@ from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.git_providers.codecommit_client import CodeCommitClient -from ..algo.utils import load_large_diff +from ..algo.utils import ( + add_pr_review_identity, + comment_carries_other_identity, + comment_matches_identity, + load_large_diff, +) from ..config_loader import get_settings from ..log import get_logger from .git_provider import GitProvider @@ -79,7 +86,6 @@ def provider_name(self): def is_supported(self, capability: str) -> bool: if capability in [ - "get_issue_comments", "create_inline_comment", "publish_inline_comments", "get_labels", @@ -195,23 +201,91 @@ def publish_description(self, pr_title: str, pr_body: str): def publish_comment(self, pr_comment: str, is_temporary: bool = False): if is_temporary: get_logger().info(pr_comment) - return - - pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment) - pr_comment = CodeCommitProvider._add_additional_newlines(pr_comment) + return None try: - for target in self._get_target_contexts(): - self.codecommit_client.publish_comment( - repo_name=target["repository_name"], - pr_number=self.pr_num, - destination_commit=target["destination_commit"], - source_commit=target["source_commit"], - comment=pr_comment, - ) + published_comments = [ + self._publish_comment_to_target(pr_comment, target) + for target in self._get_target_contexts() + ] + if len(published_comments) == 1: + return published_comments[0] + return published_comments except Exception as e: raise ValueError(f"CodeCommit Cannot publish comment for PR: {self.pr_num}") from e + def publish_persistent_comment(self, pr_comment: str, + initial_header: str, + update_header: bool = True, + name='review', + final_update_message=True, + as_thread: bool = False, + identity_marker: str | None = None, + legacy_initial_header: str | None = None): + if as_thread: + get_logger().debug("CodeCommit does not support threaded persistent comments; publishing as a PR comment") + + if not identity_marker: + get_logger().debug( + "CodeCommit persistent comment updates require a stable identity marker; publishing a new comment" + ) + return self.publish_comment(pr_comment) + + persistent_comment = add_pr_review_identity(pr_comment, identity_marker) + try: + comments = self.get_issue_comments_newest_first() + except Exception as e: + get_logger().warning( + f"CodeCommit could not read existing comments; publishing a new persistent comment: {e}" + ) + return self.publish_comment(persistent_comment) + + identifiers = [identity_marker, legacy_initial_header] + used_comment_ids = set() + published_comments = [] + target_contexts = self._get_target_contexts() + destination_counts = Counter( + (target["repository_name"], target["destination_commit"]) + for target in target_contexts + ) + allow_repository_fallback = len(target_contexts) == 1 + + for target in target_contexts: + comment_to_update = self._find_persistent_comment_for_target( + comments, + target, + identifiers, + identity_marker, + used_comment_ids, + destination_counts[(target["repository_name"], target["destination_commit"])] == 1, + allow_repository_fallback, + ) + if comment_to_update is None: + published_comments.append(self._publish_comment_to_target(persistent_comment, target)) + continue + + used_comment_ids.add(comment_to_update.id) + comment_body = self._persistent_body_for_target( + persistent_comment, + update_header, + name, + identity_marker, + target, + ) + comment_url = self.get_comment_url(comment_to_update) + get_logger().info(f"Persistent mode - updating comment {comment_url} to latest {name} message") + if self.edit_comment(comment_to_update, comment_body) is not False: + published_comments.append(comment_to_update) + continue + + published_comments.append(self._publish_comment_to_target(persistent_comment, target)) + + if final_update_message: + get_logger().debug("CodeCommit does not publish separate persistent update status comments") + if len(published_comments) == 1: + return published_comments[0] + return published_comments + def publish_code_suggestions(self, code_suggestions: list) -> bool: counter = 1 publishable_count = 0 @@ -261,6 +335,26 @@ def remove_initial_comment(self): def remove_comment(self, comment): return "" # not implemented yet + def edit_comment(self, comment, body: str): + comment_id = comment.get("id") if isinstance(comment, dict) else getattr(comment, "id", None) + if not isinstance(comment_id, str) or not comment_id: + get_logger().warning(f"CodeCommit cannot update comment without a valid comment id: {comment_id!r}") + return False + + body = self._prepare_comment_body(body) + try: + response = self.codecommit_client.update_comment(comment_id, body) + except Exception as e: + get_logger().warning(f"CodeCommit failed to update comment {comment_id}: {e}") + return False + + updated_comment = response.get("comment") if isinstance(response, dict) else None + if not isinstance(updated_comment, dict): + return False + if updated_comment.get("deleted") is True: + return False + return updated_comment.get("commentId") == comment_id and updated_comment.get("content") == body + def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/post_comment_for_compared_commit.html raise NotImplementedError("CodeCommit provider does not support publishing inline comments yet") @@ -326,7 +420,19 @@ def get_user_id(self): return -1 # not implemented yet def get_issue_comments(self): - raise NotImplementedError("CodeCommit provider does not support issue comments yet") + comments = [] + for comment_data in self.codecommit_client.get_comments_for_pull_request(self.pr_num): + comments.extend(self._extract_issue_comments(comment_data)) + return sorted(comments, key=self._comment_sort_key) + + def get_issue_comments_newest_first(self): + return sorted(self.get_issue_comments(), key=self._comment_sort_key, reverse=True) + + def supports_review_comment_identity(self) -> bool: + return True + + def get_comment_url(self, comment) -> str: + return self.get_pr_url() def get_repo_settings(self): # a local ".pr_agent.toml" settings file is optional @@ -467,6 +573,168 @@ def _get_target_contexts_for_file(self, filename: str): def get_commit_messages(self) -> str: return "" # not implemented yet + def _publish_comment_to_target(self, pr_comment: str, target: dict): + pr_comment = self._prepare_comment_body(pr_comment) + response = self.codecommit_client.publish_comment( + repo_name=target["repository_name"], + pr_number=self.pr_num, + destination_commit=target["destination_commit"], + source_commit=target["source_commit"], + comment=pr_comment, + ) + if isinstance(response, dict): + return self._comment_from_api_comment( + response.get("comment"), + { + "repositoryName": target["repository_name"], + "beforeCommitId": target["destination_commit"], + "afterCommitId": target["source_commit"], + }, + ) + return response + + def _find_persistent_comment_for_target( + self, + comments: list, + target: dict, + identifiers: list, + identity_marker: str, + used_comment_ids: set, + allow_destination_fallback: bool, + allow_repository_fallback: bool, + ): + matchers = [self._comment_matches_target_exact] + if allow_destination_fallback: + matchers.append(self._comment_matches_target_destination) + if allow_repository_fallback: + matchers.append(self._comment_matches_target_repository) + + for matcher in matchers: + comment = self._find_persistent_comment( + comments, + identifiers, + identity_marker, + used_comment_ids, + lambda candidate: matcher(candidate, target), + ) + if comment is not None: + return comment + return None + + @staticmethod + def _find_persistent_comment( + comments: list, + identifiers: list, + identity_marker: str, + used_comment_ids: set, + target_matcher, + ): + for identifier in identifiers: + if not identifier: + continue + for comment in comments: + if comment.id in used_comment_ids or not target_matcher(comment): + continue + body = GitProvider._get_comment_body(comment) + if not comment_matches_identity(body, identifier): + continue + if comment_carries_other_identity(body, identity_marker): + continue + return comment + return None + + @staticmethod + def _comment_matches_target_exact(comment, target: dict) -> bool: + return ( + CodeCommitProvider._comment_matches_target_destination(comment, target) + and getattr(comment, "after_commit_id", None) == target["source_commit"] + ) + + @staticmethod + def _comment_matches_target_destination(comment, target: dict) -> bool: + return ( + CodeCommitProvider._comment_matches_target_repository(comment, target) + and getattr(comment, "before_commit_id", None) == target["destination_commit"] + ) + + @staticmethod + def _comment_matches_target_repository(comment, target: dict) -> bool: + repository_name = getattr(comment, "repository_name", None) + return repository_name == target["repository_name"] + + @staticmethod + def _persistent_body_for_target( + pr_comment: str, + update_header: bool, + name: str, + identity_marker: str, + target: dict, + ) -> str: + if not update_header: + return pr_comment + + update_message = f"#### ({name.capitalize()} updated until commit {target['source_commit']})\n" + updated_anchor = f"{identity_marker}\n\n{update_message}" + return pr_comment.replace(identity_marker, updated_anchor, 1) + + @staticmethod + def _prepare_comment_body(pr_comment: str) -> str: + pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment) + return CodeCommitProvider._add_additional_newlines(pr_comment) + + @staticmethod + def _extract_issue_comments(comment_data: dict): + if not isinstance(comment_data, dict) or comment_data.get("location"): + return [] + + comments = [] + for comment in comment_data.get("comments", []): + issue_comment = CodeCommitProvider._comment_from_api_comment(comment, comment_data) + if issue_comment is not None: + comments.append(issue_comment) + return comments + + @staticmethod + def _comment_from_api_comment(comment: dict, comment_data: dict): + if not isinstance(comment, dict): + return None + if comment.get("deleted") is True or comment.get("inReplyTo"): + return None + + body = comment.get("content") + comment_id = comment.get("commentId") + created_at = CodeCommitProvider._comment_timestamp(comment.get("creationDate")) + if not isinstance(body, str) or not body: + return None + if not isinstance(comment_id, str) or not comment_id: + return None + if created_at is None: + return None + + author_arn = comment.get("authorArn") if isinstance(comment.get("authorArn"), str) else "" + return SimpleNamespace( + body=body, + id=comment_id, + created_at=created_at, + last_modified_at=CodeCommitProvider._comment_timestamp(comment.get("lastModifiedDate")), + repository_name=comment_data.get("repositoryName"), + before_commit_id=comment_data.get("beforeCommitId"), + after_commit_id=comment_data.get("afterCommitId"), + user=SimpleNamespace(login=author_arn), + ) + + @staticmethod + def _comment_timestamp(value): + if isinstance(value, datetime): + return value.timestamp() + if isinstance(value, (int, float)): + return float(value) + return None + + @staticmethod + def _comment_sort_key(comment): + return (comment.created_at, comment.id) + @staticmethod def _add_additional_newlines(body: str) -> str: """ diff --git a/tests/unittest/test_codecommit_client.py b/tests/unittest/test_codecommit_client.py index caa975bd07..3380b96e8d 100644 --- a/tests/unittest/test_codecommit_client.py +++ b/tests/unittest/test_codecommit_client.py @@ -164,3 +164,63 @@ def test_get_pr_preserves_all_target_repositories(self): ("repo-one", "source-one", "destination-one"), ("repo-two", "source-two", "destination-two"), ] + + def test_get_comments_for_pull_request_uses_safe_page_size_and_all_pages(self): + api = CodeCommitClient() + api.boto_client = MagicMock() + paginator = api.boto_client.get_paginator.return_value + paginator.paginate.return_value = [ + {"commentsForPullRequestData": [{"comments": [{"commentId": "comment-1"}]}]}, + {"commentsForPullRequestData": [{"comments": [{"commentId": "comment-2"}]}]}, + ] + + comments = api.get_comments_for_pull_request(321) + + assert comments == [ + {"comments": [{"commentId": "comment-1"}]}, + {"comments": [{"commentId": "comment-2"}]}, + ] + api.boto_client.get_paginator.assert_called_once_with("get_comments_for_pull_request") + paginator.paginate.assert_called_once_with( + pullRequestId="321", + PaginationConfig={"PageSize": 100}, + ) + + def test_publish_comment_returns_boto_response(self): + api = CodeCommitClient() + api.boto_client = MagicMock() + api.boto_client.post_comment_for_pull_request.return_value = { + "comment": {"commentId": "comment-1", "content": "Review"} + } + + response = api.publish_comment( + repo_name="my_test_repo", + pr_number=321, + destination_commit="destination-commit", + source_commit="source-commit", + comment="Review", + ) + + assert response == {"comment": {"commentId": "comment-1", "content": "Review"}} + api.boto_client.post_comment_for_pull_request.assert_called_once_with( + pullRequestId="321", + repositoryName="my_test_repo", + beforeCommitId="destination-commit", + afterCommitId="source-commit", + content="Review", + ) + + def test_update_comment_returns_boto_response(self): + api = CodeCommitClient() + api.boto_client = MagicMock() + api.boto_client.update_comment.return_value = { + "comment": {"commentId": "comment-1", "content": "Updated review"} + } + + response = api.update_comment("comment-1", "Updated review") + + assert response == {"comment": {"commentId": "comment-1", "content": "Updated review"}} + api.boto_client.update_comment.assert_called_once_with( + commentId="comment-1", + content="Updated review", + ) diff --git a/tests/unittest/test_codecommit_provider.py b/tests/unittest/test_codecommit_provider.py index 09a42e5782..b5a47ac863 100644 --- a/tests/unittest/test_codecommit_provider.py +++ b/tests/unittest/test_codecommit_provider.py @@ -1,9 +1,13 @@ +from datetime import datetime, timezone +from types import SimpleNamespace from unittest.mock import MagicMock, call, patch import pytest from pr_agent.algo.types import EDIT_TYPE +from pr_agent.algo.utils import PRReviewHeader, PRReviewIdentity from pr_agent.git_providers.codecommit_provider import CodeCommitFile, CodeCommitProvider, PullRequestCCMimic +from pr_agent.tools.pr_reviewer import PRReviewer class TestCodeCommitFile: @@ -39,6 +43,74 @@ def _make_diff_provider(git_files): provider.codecommit_client = MagicMock() return provider + @staticmethod + def _make_persistent_provider(targets=None): + provider = object.__new__(CodeCommitProvider) + provider.repo_name = "source-repository" + provider.pr_num = 321 + provider.pr_url = ( + "https://us-east-1.console.aws.amazon.com/codesuite/codecommit/" + "repositories/source-repository/pull-requests/321" + ) + provider.diff_files = None + provider.git_files = [] + provider.codecommit_client = MagicMock() + targets = targets or [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + ) + ] + provider.pr = PullRequestCCMimic("Persistent PR", [], targets=targets) + provider.pr.source_commit = targets[0].source_commit + provider.pr.source_branch = targets[0].source_branch + provider.pr.destination_commit = targets[0].destination_commit + provider.pr.destination_branch = targets[0].destination_branch + return provider + + @staticmethod + def _comment( + body, + comment_id="comment-1", + created_at=None, + last_modified_at=None, + **overrides, + ): + comment = { + "commentId": comment_id, + "content": body, + "creationDate": created_at or datetime(2024, 1, 1, tzinfo=timezone.utc), + "lastModifiedDate": last_modified_at or created_at or datetime(2024, 1, 1, tzinfo=timezone.utc), + "authorArn": "arn:aws:iam::123456789012:user/pr-agent", + } + comment.update(overrides) + return comment + + @staticmethod + def _comment_group( + *comments, + repository_name="source-repository", + before_commit_id="old-destination-commit", + after_commit_id="old-source-commit", + location=None, + ): + group = { + "repositoryName": repository_name, + "beforeCommitId": before_commit_id, + "afterCommitId": after_commit_id, + "comments": list(comments), + } + if location is not None: + group["location"] = location + return group + + @staticmethod + def _successful_update_response(comment_id, body): + return {"comment": {"commentId": comment_id, "content": body, "deleted": False}} + def test_get_diff_files_includes_deleted_file(self): provider = object.__new__(CodeCommitProvider) provider.repo_name = "my_test_repo" @@ -241,6 +313,477 @@ def test_publish_comment_uses_every_pull_request_target(self): ), ] + def test_persistent_review_creates_then_updates_each_pull_request_target(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-2", + source_branch="refs/heads/feature", + destination_commit="destination-commit-2", + destination_branch="refs/heads/release", + ), + ] + provider = self._make_persistent_provider(targets=targets) + provider.codecommit_client.get_comments_for_pull_request.return_value = [] + + def publish_comment(**kwargs): + return { + "comment": self._comment( + kwargs["comment"], + comment_id=f"comment-{kwargs['source_commit']}", + ) + } + + provider.codecommit_client.publish_comment.side_effect = publish_comment + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + header = "## Team Review" + + created = provider.publish_persistent_comment( + f"{header}\n\nfirst review", + initial_header=header, + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + first_bodies = { + call.kwargs["source_commit"]: call.kwargs["comment"] + for call in provider.codecommit_client.publish_comment.call_args_list + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(first_bodies["source-commit-1"], comment_id="comment-source-commit-1"), + before_commit_id="destination-commit-1", + after_commit_id="source-commit-1", + ), + self._comment_group( + self._comment(first_bodies["source-commit-2"], comment_id="comment-source-commit-2"), + before_commit_id="destination-commit-2", + after_commit_id="source-commit-2", + ), + ] + provider.codecommit_client.publish_comment.reset_mock() + + updated = provider.publish_persistent_comment( + f"{header}\n\nsecond review", + initial_header=header, + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert [comment.id for comment in created] == ["comment-source-commit-1", "comment-source-commit-2"] + assert [comment.id for comment in updated] == ["comment-source-commit-1", "comment-source-commit-2"] + provider.codecommit_client.publish_comment.assert_not_called() + assert provider.codecommit_client.update_comment.call_count == 2 + update_bodies = { + call.args[0]: call.args[1] + for call in provider.codecommit_client.update_comment.call_args_list + } + assert set(update_bodies) == {"comment-source-commit-1", "comment-source-commit-2"} + assert PRReviewIdentity.REGULAR.value in first_bodies["source-commit-1"] + assert PRReviewIdentity.REGULAR.value in update_bodies["comment-source-commit-1"] + assert PRReviewIdentity.REGULAR.value in update_bodies["comment-source-commit-2"] + assert "second review" in update_bodies["comment-source-commit-1"] + assert "Review updated until commit source-commit-1" in update_bodies["comment-source-commit-1"] + assert "Review updated until commit source-commit-2" in update_bodies["comment-source-commit-2"] + + def test_persistent_review_updates_comment_from_older_commit_pair_for_single_target(self): + provider = self._make_persistent_provider() + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(existing_body, comment_id="older-commit-comment"), + before_commit_id="older-destination-commit", + after_commit_id="older-source-commit", + ) + ] + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + + result = provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert result.id == "older-commit-comment" + provider.codecommit_client.publish_comment.assert_not_called() + comment_id, updated_body = provider.codecommit_client.update_comment.call_args.args + assert comment_id == "older-commit-comment" + assert "Review updated until commit source-commit-1" in updated_body + + def test_persistent_review_falls_back_to_new_comment_when_comment_listing_fails(self): + provider = self._make_persistent_provider() + provider.codecommit_client.get_comments_for_pull_request.side_effect = ValueError("AccessDenied") + provider.codecommit_client.publish_comment.return_value = { + "comment": self._comment("created body", comment_id="fallback-comment") + } + + result = provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert result.id == "fallback-comment" + provider.codecommit_client.update_comment.assert_not_called() + provider.codecommit_client.publish_comment.assert_called_once() + assert PRReviewIdentity.REGULAR.value in provider.codecommit_client.publish_comment.call_args.kwargs["comment"] + + def test_persistent_review_does_not_use_repository_only_match_for_multiple_targets(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-2", + source_branch="refs/heads/feature", + destination_commit="destination-commit-2", + destination_branch="refs/heads/release", + ), + ] + provider = self._make_persistent_provider(targets=targets) + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(existing_body, comment_id="unmatched-comment"), + before_commit_id="older-destination-commit", + after_commit_id="older-source-commit", + ) + ] + + provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.update_comment.assert_not_called() + assert provider.codecommit_client.publish_comment.call_args_list == [ + call( + repo_name="source-repository", + pr_number=321, + destination_commit="destination-commit-1", + source_commit="source-commit-1", + comment=provider.codecommit_client.publish_comment.call_args_list[0].kwargs["comment"], + ), + call( + repo_name="source-repository", + pr_number=321, + destination_commit="destination-commit-2", + source_commit="source-commit-2", + comment=provider.codecommit_client.publish_comment.call_args_list[1].kwargs["comment"], + ), + ] + + def test_persistent_review_does_not_use_ambiguous_destination_match_for_multiple_targets(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-a-new", + source_branch="refs/heads/feature-a", + destination_commit="shared-destination-commit", + destination_branch="refs/heads/main", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-b-new", + source_branch="refs/heads/feature-b", + destination_commit="shared-destination-commit", + destination_branch="refs/heads/main", + ), + ] + provider = self._make_persistent_provider(targets=targets) + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment( + existing_body, + comment_id="target-b-old", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + before_commit_id="shared-destination-commit", + after_commit_id="source-commit-b-old", + ), + self._comment_group( + self._comment( + existing_body, + comment_id="target-a-old", + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + before_commit_id="shared-destination-commit", + after_commit_id="source-commit-a-old", + ), + ] + + provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.update_comment.assert_not_called() + assert [ + call.kwargs["source_commit"] + for call in provider.codecommit_client.publish_comment.call_args_list + ] == ["source-commit-a-new", "source-commit-b-new"] + + @pytest.mark.parametrize("raw_order", [("old", "new"), ("new", "old")]) + def test_get_issue_comments_uses_oldest_first_creation_order_for_answer(self, raw_order): + provider = self._make_persistent_provider() + comments_by_name = { + "old": self._comment( + "older answer", + comment_id="old-comment", + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + last_modified_at=datetime(2024, 1, 10, tzinfo=timezone.utc), + ), + "new": self._comment( + "newer answer", + comment_id="new-comment", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(*(comments_by_name[name] for name in raw_order)) + ] + + comments = provider.get_issue_comments() + + assert provider.is_supported("get_issue_comments") is True + assert provider.supports_review_comment_identity() is True + assert [comment.id for comment in comments] == ["old-comment", "new-comment"] + assert comments[0].user.login == "arn:aws:iam::123456789012:user/pr-agent" + provider.codecommit_client.get_comments_for_pull_request.assert_called_once_with(321) + + @pytest.mark.parametrize("raw_order", [("q-old", "a-old", "q-new", "a-new"), ("a-new", "q-new", "a-old", "q-old")]) + def test_answer_mode_uses_newest_question_and_answer_after_oldest_first_sort(self, raw_order): + provider = self._make_persistent_provider() + bodies_by_name = { + "q-old": "Questions to better understand the PR:\n\nold question", + "a-old": "/answer old answer", + "q-new": "Questions to better understand the PR:\n\nnew question", + "a-new": "/answer new answer", + } + dates_by_name = { + "q-old": datetime(2024, 1, 1, tzinfo=timezone.utc), + "a-old": datetime(2024, 1, 2, tzinfo=timezone.utc), + "q-new": datetime(2024, 1, 3, tzinfo=timezone.utc), + "a-new": datetime(2024, 1, 4, tzinfo=timezone.utc), + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + *( + self._comment( + bodies_by_name[name], + comment_id=name, + created_at=dates_by_name[name], + ) + for name in raw_order + ) + ) + ] + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_answer = True + reviewer.git_provider = provider + + question, answer = reviewer._get_user_answers() + + assert question == bodies_by_name["q-new"] + assert answer == bodies_by_name["a-new"] + + @pytest.mark.parametrize("raw_order", [("old", "new"), ("new", "old")]) + def test_persistent_review_updates_newest_duplicate_under_both_api_feed_orders(self, raw_order): + provider = self._make_persistent_provider() + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + comments_by_name = { + "old": self._comment( + body, + comment_id="old-comment", + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + last_modified_at=datetime(2024, 1, 10, tzinfo=timezone.utc), + ), + "new": self._comment( + body, + comment_id="new-comment", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(*(comments_by_name[name] for name in raw_order)) + ] + + provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.update_comment.assert_called_once() + assert provider.codecommit_client.update_comment.call_args.args[0] == "new-comment" + provider.codecommit_client.publish_comment.assert_not_called() + + def test_get_issue_comments_filters_deleted_replies_inline_and_malformed_comments(self): + provider = self._make_persistent_provider() + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + "not a group", + self._comment_group(self._comment("valid old", comment_id="valid-old")), + self._comment_group( + self._comment("reply", comment_id="reply", inReplyTo="valid-old"), + self._comment("deleted", comment_id="deleted", deleted=True), + self._comment("missing id", commentId=None), + self._comment("", comment_id="empty-body"), + self._comment("bad timestamp", comment_id="bad-timestamp", creationDate="yesterday"), + ), + self._comment_group( + self._comment("inline", comment_id="inline"), + location={"filePath": "src/app.py", "filePosition": 7}, + ), + self._comment_group( + self._comment( + "valid new", + comment_id="valid-new", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ) + ), + ] + + comments = provider.get_issue_comments() + + assert [(comment.id, comment.body) for comment in comments] == [ + ("valid-old", "valid old"), + ("valid-new", "valid new"), + ] + + def test_persistent_review_migrates_legacy_heading_to_stable_identity(self): + provider = self._make_persistent_provider() + legacy_body = f"{PRReviewHeader.REGULAR.value} 🔍\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(self._comment(legacy_body, comment_id="legacy-comment")) + ] + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + + provider.publish_persistent_comment( + "## Custom Review\n\nnew review", + initial_header="## Custom Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.publish_comment.assert_not_called() + comment_id, updated_body = provider.codecommit_client.update_comment.call_args.args + assert comment_id == "legacy-comment" + assert updated_body.startswith("## Custom Review\n\n") + assert PRReviewIdentity.REGULAR.value in updated_body + + def test_persistent_review_falls_back_to_new_comment_only_for_failed_target_update(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-2", + source_branch="refs/heads/feature", + destination_commit="destination-commit-2", + destination_branch="refs/heads/release", + ), + ] + provider = self._make_persistent_provider(targets=targets) + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(existing_body, comment_id="comment-target-1"), + before_commit_id="destination-commit-1", + after_commit_id="source-commit-1", + ), + self._comment_group( + self._comment(existing_body, comment_id="comment-target-2"), + before_commit_id="destination-commit-2", + after_commit_id="source-commit-2", + ), + ] + + def update_comment(comment_id, body): + if comment_id == "comment-target-2": + return {"comment": {"commentId": comment_id, "content": "unexpected body", "deleted": False}} + return self._successful_update_response(comment_id, body) + + provider.codecommit_client.update_comment.side_effect = update_comment + provider.codecommit_client.publish_comment.return_value = { + "comment": self._comment("fallback body", comment_id="fallback-comment") + } + + result = provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert [comment.id for comment in result] == ["comment-target-1", "fallback-comment"] + assert provider.codecommit_client.update_comment.call_count == 2 + provider.codecommit_client.publish_comment.assert_called_once() + assert provider.codecommit_client.publish_comment.call_args.kwargs["repo_name"] == "source-repository" + assert provider.codecommit_client.publish_comment.call_args.kwargs["destination_commit"] == "destination-commit-2" + assert provider.codecommit_client.publish_comment.call_args.kwargs["source_commit"] == "source-commit-2" + + def test_unmarked_persistent_comment_remains_create_only(self): + provider = self._make_persistent_provider() + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(self._comment("## Title\n\nold description", comment_id="describe-comment")) + ] + provider.codecommit_client.publish_comment.return_value = { + "comment": self._comment("## Title\n\nnew description", comment_id="new-describe-comment") + } + + result = provider.publish_persistent_comment( + "## Title\n\nnew description", + initial_header="## Title", + update_header=True, + name="describe", + final_update_message=False, + ) + + assert result.id == "new-describe-comment" + provider.codecommit_client.get_comments_for_pull_request.assert_not_called() + provider.codecommit_client.update_comment.assert_not_called() + provider.codecommit_client.publish_comment.assert_called_once() + + def test_get_issue_comments_support_does_not_enable_gfm_improve_history(self): + provider = self._make_persistent_provider() + + assert provider.is_supported("get_issue_comments") is True + assert provider.is_supported("gfm_markdown") is False + def test_publish_code_suggestion_uses_target_that_contains_file(self): provider = object.__new__(CodeCommitProvider) provider.repo_name = "source-repository" diff --git a/tests/unittest/test_git_provider_method_contract.py b/tests/unittest/test_git_provider_method_contract.py index f808732155..a67612f229 100644 --- a/tests/unittest/test_git_provider_method_contract.py +++ b/tests/unittest/test_git_provider_method_contract.py @@ -8,6 +8,7 @@ import inspect from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timezone from enum import Enum from types import SimpleNamespace from typing import get_type_hints @@ -137,6 +138,7 @@ def _bitbucket_server(monkeypatch) -> BitbucketServerProvider: }] return provider + def _bitbucket(monkeypatch) -> BitbucketProvider: provider = BitbucketProvider.__new__(BitbucketProvider) provider.pr = MagicMock() @@ -148,6 +150,38 @@ def _bitbucket(monkeypatch) -> BitbucketProvider: return provider +def _codecommit(monkeypatch) -> CodeCommitProvider: + provider = CodeCommitProvider.__new__(CodeCommitProvider) + provider.repo_name = "repo" + provider.pr_num = 7 + provider.pr_url = "https://us-east-1.console.aws.amazon.com/codesuite/codecommit/repositories/repo/pull-requests/7" + provider.pr = SimpleNamespace( + source_commit="source", + destination_commit="destination", + targets=[ + SimpleNamespace( + repository_name="repo", + source_commit="source", + destination_commit="destination", + ) + ], + ) + provider.codecommit_client = MagicMock() + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + { + "repositoryName": "repo", + "beforeCommitId": "destination", + "afterCommitId": "source", + "comments": [{ + "commentId": "comment-1", + "content": COMMENT_BODY, + "creationDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + }], + } + ] + return provider + + def _bare(provider_type): """A provider with no backend wired at all: every call on it must succeed without one.""" return lambda monkeypatch: provider_type.__new__(provider_type) @@ -161,7 +195,7 @@ def _bare(provider_type): "azure-devops": (AzureDevopsProvider, _azure_devops), "bitbucket": (BitbucketProvider, _bitbucket), "bitbucket-server": (BitbucketServerProvider, _bitbucket_server), - "codecommit": (CodeCommitProvider, _bare(CodeCommitProvider)), + "codecommit": (CodeCommitProvider, _codecommit), "local": (LocalGitProvider, _bare(LocalGitProvider)), "plain-diff": (PlainDiffGitProvider, _bare(PlainDiffGitProvider)), "mosaico-diff": (DiffInputProvider, _bare(DiffInputProvider)), @@ -233,8 +267,17 @@ def _is_success(value): noop_value=[], check_supported=_is_comment_sequence, tiers=_tiers( - supported=("github", "gitlab", "gitea", "gerrit", "azure-devops", "bitbucket-server", "bitbucket"), - not_implemented=("codecommit", "local"), + supported=( + "github", + "gitlab", + "gitea", + "gerrit", + "azure-devops", + "bitbucket-server", + "bitbucket", + "codecommit", + ), + not_implemented=("local",), ), # Implementations narrow the base `Iterable` (a paginated list, a list of SDK objects), # so the return annotation is checked by behaviour rather than by equality. From a9f15a55a79b9a9df6930c9731eb8f8f70fb006e Mon Sep 17 00:00:00 2001 From: Carlos0196 Date: Thu, 10 Sep 2026 14:25:37 +0200 Subject: [PATCH 038/238] fix: drop a sign-off the model adds after the closing fence (#3243) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: IsmaelMartinez --- pr_agent/algo/utils.py | 49 ++++++++++ .../unittest/test_load_yaml_trailing_text.py | 96 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/unittest/test_load_yaml_trailing_text.py diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index ca9b02ca25..4bb543276e 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -983,6 +983,54 @@ def sanitize_yaml_control_chars(text: str, log: bool = True) -> str: return sanitized +def _looks_like_more_answer(tail: str) -> bool: + """Whether the text after the fence is more of the answer rather than a sign-off. + + Two signals, because each alone has a blind spot: a tail that parses as a mapping or a + list is structured, but one that continues into prose does not parse at all and is + only recognisable from the shape of its first line. + """ + first_line = next((line for line in tail.split('\n') if line.strip()), '') + if re.match(r'^[A-Za-z_][A-Za-z0-9_]*:(\s|$)', first_line): + return True + try: + return isinstance(yaml.safe_load(tail), (dict, list)) + except Exception: + return False + + +def drop_sign_off_after_wrapper_fence(text: str) -> str: + """Drop a closing remark the model added after the wrapper's closing fence. + + The prompts ask for YAML "and nothing else", but the model sometimes signs off + anyway. That either leaves the document unparseable or, for a single block + scalar, parses the fence and the remark into the value. + + No existing fallback recovers it. The one that extracts a fenced block needs + both fences, but most prompts end with an open fence for the model to continue + from, so the reply carries only the closing one. + """ + lines = text.split('\n') + for i in range(len(lines) - 1, -1, -1): + if lines[i].rstrip() != '```': + continue + tail = '\n'.join(lines[i + 1:]) + if not tail.strip(): + return text + if _looks_like_more_answer(tail): + # Dropping it would publish a partial answer, where the parse failure it + # replaces at least triggers a retry. + return text + candidate = '\n'.join(lines[:i]) + try: + if isinstance(yaml.safe_load(candidate), dict): + return candidate + except Exception: + pass + return text + return text + + def load_yaml(response_text: str, keys_fix_yaml: List[str] | None = None, first_key="", last_key="") -> dict: if keys_fix_yaml is None: keys_fix_yaml = [] @@ -994,6 +1042,7 @@ def load_yaml(response_text: str, keys_fix_yaml: List[str] | None = None, first_ if unfenced == response_text: unfenced = response_text.removeprefix('yaml') response_text = unfenced.rstrip() + response_text = drop_sign_off_after_wrapper_fence(response_text) if response_text.split('\n')[-1] == '```': response_text = response_text.removesuffix('```') response_text = sanitize_yaml_control_chars(response_text) diff --git a/tests/unittest/test_load_yaml_trailing_text.py b/tests/unittest/test_load_yaml_trailing_text.py new file mode 100644 index 0000000000..3a5d0c4dea --- /dev/null +++ b/tests/unittest/test_load_yaml_trailing_text.py @@ -0,0 +1,96 @@ +"""Drop a sign-off the model added after the wrapper's closing fence.""" +from pr_agent.algo.utils import load_yaml + +SIGN_OFF = "\n\nI reviewed the diff and found nothing else worth flagging." + +# The prompts ask for YAML "and nothing else", but 8 of the 12 user prompts end +# with a dangling open ```yaml, so a reply carries either both fences or, more +# often, a closing one only. +WRAPPED = "```yaml\ncode_suggestions: []\n```" +PRIMED = "code_suggestions: []\n```" + + +def test_a_wrapped_mapping_survives_a_sign_off(): + """The snippet fallback cannot recover this: its pattern requires the + closing fence to end the response.""" + assert load_yaml(WRAPPED + SIGN_OFF, first_key="code_suggestions", + last_key="label") == {"code_suggestions": []} + + +def test_a_primed_mapping_survives_a_sign_off(): + """A reply with a closing fence and no opening one cannot be recovered by + any fence pattern, since there is no pair to match.""" + assert load_yaml(PRIMED + SIGN_OFF, first_key="code_suggestions", + last_key="label") == {"code_suggestions": []} + + +def test_a_sign_off_is_not_folded_into_a_block_scalar(): + """Worse than a parse failure: a single block scalar absorbs the fence and + the remark as part of its value, so the answer is published corrupted.""" + wrapped = "```yaml\nresponse: |\n hello\n```" + SIGN_OFF + + assert load_yaml(wrapped)["response"].strip() == "hello" + + +def test_an_answer_following_a_fenced_example_is_not_truncated(): + """Dropping text after that fence would leave a plain scalar, so the + response must be left alone for the fallbacks to handle.""" + assert load_yaml("```\nexample\n```\nresponse: hello") == {} + + +def test_a_fence_inside_a_block_scalar_is_not_treated_as_the_wrapper(): + """Only a fence at content level closes the wrapper.""" + answer = load_yaml("response: |\n Use this:\n ```python\n print(1)\n ```\n") + + assert answer["response"].count("```") == 2 + + +def test_trailing_whitespace_after_the_fence_changes_nothing(): + assert load_yaml("```yaml\nresponse: |\n hello\n``` \n\n")["response"].strip() == "hello" + + +def test_payload_continuing_past_the_fence_still_fails_loudly(): + """A stray fence mid-answer must not yield a partial mapping. Failing keeps the + retry that a truncated answer would quietly replace.""" + assert load_yaml("a: 1\n```\nb: 2") == {} + + +def test_a_list_continuing_past_the_fence_still_fails_loudly(): + """The /improve shape: a suggestion list truncated to its first item would be + published as if it were complete.""" + reply = "code_suggestions:\n - one\n```\n - two" + + assert load_yaml(reply, first_key="code_suggestions", last_key="label") == {} + + +def test_a_nested_mapping_continuing_past_the_fence_still_fails_loudly(): + assert load_yaml("review:\n score: 1\n```\n effort: 2") == {} + + +def test_a_continuation_followed_by_a_sign_off_still_fails_loudly(): + """The tail is a mapping and then prose, so the tail as a whole does not parse. + Only the shape of its first line marks it as more answer rather than a remark.""" + reply = ("code_suggestions:\n - one\n```\nsecurity_concerns: 'No'\nscore: 8\n\n" + "Hope this helps.") + + assert load_yaml(reply, first_key="code_suggestions", last_key="security_concerns") == {} + + +def test_a_crlf_reply_is_repaired(): + """Provider payloads arrive with CRLF, so the fence line ends with \\r.""" + reply = "```yaml\r\nresponse: |\r\n hello\r\n```\r\n\r\nThat is my answer." + + assert load_yaml(reply)["response"].strip() == "hello" + + +def test_a_sign_off_that_reads_as_a_mapping_is_left_alone(): + """The cost of the rule above: nothing tells 'Note: ...' apart from payload, so + such a reply keeps the pre-existing behaviour rather than risking a truncation.""" + assert load_yaml("a: 1\n```\n\nNote: nothing else stood out.") == {} + + +def test_a_second_fenced_block_is_left_to_the_existing_behaviour(): + """Nothing follows the last fence, so there is no sign-off to drop.""" + reply = "```yaml\nresponse: |\n hello\n```\n\n```\nprint(1)\n```" + + assert "hello" in load_yaml(reply)["response"] From 22248a55c51f0cf2f39123369b9ea60d8471adcd Mon Sep 17 00:00:00 2001 From: Aurora Date: Thu, 10 Sep 2026 15:45:23 +0200 Subject: [PATCH 039/238] docs: repair four dead outbound links (#3270) Co-authored-by: auroraxo --- docs/docs/installation/github.md | 2 +- docs/docs/usage-guide/changing_a_model.md | 2 +- docs/docs/usage-guide/configuration_options.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/installation/github.md b/docs/docs/installation/github.md index addb18b1d5..ef708a040e 100644 --- a/docs/docs/installation/github.md +++ b/docs/docs/installation/github.md @@ -616,7 +616,7 @@ For more detailed configuration options, see: ... ``` - For enhanced security, you can also specify the Docker image by its [digest](https://hub.docker.com/repository/docker/pragent/pr-agent/tags). Resolve the digest for the version you are pinning with `docker buildx imagetools inspect pragent/pr-agent:0.41.0-github_action --format '{{.Manifest.Digest}}'`, then use it in place of the tag: + For enhanced security, you can also specify the Docker image by its [digest](https://hub.docker.com/r/pragent/pr-agent/tags). Resolve the digest for the version you are pinning with `docker buildx imagetools inspect pragent/pr-agent:0.41.0-github_action --format '{{.Manifest.Digest}}'`, then use it in place of the tag: ```yaml ... steps: diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 1efef809a0..01af81e4cc 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -105,7 +105,7 @@ duplicate_examples=true # will duplicate the examples in the prompt, to help the api_base = "http://localhost:11434" # or whatever port you're running Ollama on ``` -By default, Ollama uses a context window size of 2048 tokens. In most cases this is not enough to cover pr-agent prompt and pull-request diff. Context window size can be overridden with the `OLLAMA_CONTEXT_LENGTH` environment variable. For example, to set the default context length to 8K, use: `OLLAMA_CONTEXT_LENGTH=8192 ollama serve`. More information you can find on the [official ollama faq](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-specify-the-context-window-size). +By default, Ollama uses a context window size of 2048 tokens. In most cases this is not enough to cover pr-agent prompt and pull-request diff. Context window size can be overridden with the `OLLAMA_CONTEXT_LENGTH` environment variable. For example, to set the default context length to 8K, use: `OLLAMA_CONTEXT_LENGTH=8192 ollama serve`. More information you can find on the [official ollama faq](https://docs.ollama.com/faq#how-can-i-specify-the-context-window-size). Please note that the `custom_model_max_tokens` setting should be configured in accordance with the `OLLAMA_CONTEXT_LENGTH`. Failure to do so may result in unexpected model output. diff --git a/docs/docs/usage-guide/configuration_options.md b/docs/docs/usage-guide/configuration_options.md index 43ce5aed12..1f356c1a96 100644 --- a/docs/docs/usage-guide/configuration_options.md +++ b/docs/docs/usage-guide/configuration_options.md @@ -89,11 +89,11 @@ Loading the global settings file is controlled by the `use_global_settings_file` use_global_settings_file = false ``` -For example, in the GitHub organization `qodo-ai`: +For example, in a GitHub organization named `my-org`: -- The file [`https://github.com/the-pr-agent/pr-agent-settings/.pr_agent.toml`](https://github.com/the-pr-agent/pr-agent-settings/blob/main/.pr_agent.toml) serves as a global configuration file for all the repos in the GitHub organization `qodo-ai`. +- The file `my-org/pr-agent-settings/.pr_agent.toml` (read from that repository's default branch) serves as a global configuration file for all the repos in the organization. -- The repo [`https://github.com/the-pr-agent/pr-agent`](https://github.com/the-pr-agent/pr-agent/blob/main/.pr_agent.toml) inherits the global configuration file from `pr-agent-settings`. +- A repository such as `my-org/my-repo` inherits that global configuration file, and may override any of its values in its own `.pr_agent.toml`. ## Project/Group level configuration file From fa5a3c7c3fae9b263faed7cf03ced240d9ecdce3 Mon Sep 17 00:00:00 2001 From: Aurora Date: Thu, 10 Sep 2026 15:50:54 +0200 Subject: [PATCH 040/238] refactor(describe): select help text by provider capability (#3244) Co-authored-by: Aurora Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- pr_agent/git_providers/git_provider.py | 9 ++ pr_agent/git_providers/github_provider.py | 3 + pr_agent/tools/pr_description.py | 6 +- .../test_pr_description_help_rendering.py | 114 ++++++++++++++++++ 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/unittest/test_pr_description_help_rendering.py diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index b272272529..d3df6f4044 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -206,6 +206,15 @@ def supports_issue_indexing(cls) -> bool: """ return False + def supports_inline_help_footer(self) -> bool: + """Whether the `/describe` help footer is rendered as an inline `
  • ` list. + + Scoped to that footer's layout, not to HTML lists in general: the changes + walkthrough already emits `
  • \n" elif 'pr_files' in key.lower() and get_settings().pr_description.enable_semantic_files_types: # 'File Walkthrough' section - changes_walkthrough_table, pr_file_changes = self.process_pr_files_prediction(changes_walkthrough, value) + changes_walkthrough_table = self.process_pr_files_prediction(changes_walkthrough, value) if get_settings().pr_description.get('file_table_collapsible_open_by_default', False): initial_status = " open" else: @@ -754,7 +749,7 @@ def _prepare_pr_answer(self) -> Tuple[str, str, str, List[dict]]: if idx < len(self.data) - 1: pr_body += "\n\n___\n\n" - return title, pr_body, changes_walkthrough, pr_file_changes, + return title, pr_body, changes_walkthrough def _prepare_file_labels(self): file_label_dict = {} @@ -791,7 +786,6 @@ def _prepare_file_labels(self): return file_label_dict def process_pr_files_prediction(self, pr_body, value): - pr_comments = [] # logic for using collapsible file list use_collapsible_file_list = get_settings().pr_description.collapsible_file_list num_files = 0 @@ -802,7 +796,7 @@ def process_pr_files_prediction(self, pr_body, value): use_collapsible_file_list = num_files > self.COLLAPSIBLE_FILE_LIST_THRESHOLD if not self.git_provider.is_supported("gfm_markdown"): - return pr_body, pr_comments + return pr_body try: pr_body += "
    " header = "Relevant files" @@ -868,7 +862,7 @@ def process_pr_files_prediction(self, pr_body, value): except Exception as e: get_logger().error(f"Error processing pr files to markdown {self.pr_id}: {str(e)}") pass - return pr_body, pr_comments + return pr_body def add_file_data(self, delta_nbsp, diff_plus_minus, file_change_description_br, filename, filename_publish, link, pr_body) -> str: diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index d58114b717..f4c0102832 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 298 + assert sum(keys.values()) == 297 assert { "model", diff --git a/tests/unittest/test_gitlab_provider.py b/tests/unittest/test_gitlab_provider.py index 1e06cd786c..98d6b83c37 100644 --- a/tests/unittest/test_gitlab_provider.py +++ b/tests/unittest/test_gitlab_provider.py @@ -1929,7 +1929,6 @@ def test_persistent_state_ownership_uses_authenticated_user_id(self): @pytest.mark.parametrize("capability", [ "create_inline_comment", "publish_inline_comments", - "publish_file_comments", ]) def test_unimplemented_capabilities_stay_unsupported(self, capability): assert self._provider().is_supported(capability) is False diff --git a/tests/unittest/test_plain_diff_provider.py b/tests/unittest/test_plain_diff_provider.py index f9c0912d15..9c9d9c7a23 100644 --- a/tests/unittest/test_plain_diff_provider.py +++ b/tests/unittest/test_plain_diff_provider.py @@ -174,13 +174,6 @@ def test_temporary_comment_not_emitted(cfg, capsys): assert "Preparing review" not in captured.out -def test_publish_file_comments_not_supported(cfg): - cfg("plain_diff.content", DIFF) - cfg("plain_diff.output_path", None) - provider = PlainDiffGitProvider(None) - assert provider.is_supported("publish_file_comments") is False - - def test_path_traversal_file_not_read(cfg, tmp_path, monkeypatch): # SENTINEL TEST: this test FAILS if the path-traversal guard in # PlainDiffGitProvider.get_diff_files() is removed. diff --git a/tests/unittest/test_pr_description.py b/tests/unittest/test_pr_description.py index 9ec865cee7..612d3e6218 100644 --- a/tests/unittest/test_pr_description.py +++ b/tests/unittest/test_pr_description.py @@ -301,14 +301,12 @@ def test_prepare_pr_answer_with_markers_replaces_plain_and_comment_markers(self, "changes_diagram": "\n```mermaid\ngraph LR\nA --> B\n```", } - title, body, walkthrough, file_changes = obj._prepare_pr_answer_with_markers() + title, body = obj._prepare_pr_answer_with_markers() assert title == "AI title" assert "Bug fix" in body assert "Fixes the cache invalidation bug." in body assert "```mermaid" in body - assert walkthrough == "" - assert file_changes == [] @pytest.mark.asyncio async def test_extend_uncovered_files_adds_missing_diff_files_to_prediction(self): diff --git a/tests/unittest/test_pr_description_help_rendering.py b/tests/unittest/test_pr_description_help_rendering.py index d9b3153813..ef72c3e4bb 100644 --- a/tests/unittest/test_pr_description_help_rendering.py +++ b/tests/unittest/test_pr_description_help_rendering.py @@ -77,7 +77,7 @@ async def render_description_help(provider, monkeypatch) -> str: description.data = None description.file_label_dict = None description._prepare_data = MagicMock() - description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "", [])) + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "")) monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) diff --git a/tests/unittest/test_pr_description_lifecycle.py b/tests/unittest/test_pr_description_lifecycle.py index c7a5c89d45..41f5a4f433 100644 --- a/tests/unittest/test_pr_description_lifecycle.py +++ b/tests/unittest/test_pr_description_lifecycle.py @@ -160,7 +160,7 @@ async def test_run_appends_partial_description_coverage_to_output(monkeypatch): description.description_total_chunk_count = 2 description.description_failed_files = ["src/file2.py"] description._prepare_data = MagicMock() - description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "", [])) + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "")) monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) @@ -195,7 +195,7 @@ async def test_run_reports_description_publication_failure(monkeypatch, propagat description = _make_description(provider) description.prediction = "generated" description._prepare_data = MagicMock() - description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "", [])) + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "")) monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) @@ -223,3 +223,40 @@ async def test_run_reports_description_publication_failure(monkeypatch, propagat provider.remove_comment.assert_called_once_with(progress_comment) finally: restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +async def test_run_always_includes_walkthrough_in_the_description_body(monkeypatch): + # Regression guard for #3116: a provider advertising inline-comment capability support used + # to strip the File Walkthrough from the description body and publish nothing in its place. + # The walkthrough must always be appended on the non-marker rendering path. + settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) + try: + provider = MagicMock() + provider.is_supported.return_value = True + description = _make_description(provider) + description.prediction = "generated" + description._prepare_data = MagicMock() + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Base body", "WALKTHROUGH")) + + monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) + _configure_published_run() + settings = get_settings() + settings.pr_description.enable_help_comment = False + settings.pr_description.enable_help_text = False + settings.pr_description.enable_semantic_files_types = False + settings.pr_description.final_update_message = False + settings.pr_description.generate_ai_title = True + settings.pr_description.publish_description_as_comment = False + settings.pr_description.publish_labels = False + settings.pr_description.use_description_markers = False + + await description.run() + + provider.publish_description.assert_called_once() + title, body = provider.publish_description.call_args.args + assert title == "AI title" + assert "WALKTHROUGH" in body + finally: + restore_settings(settings_snapshot) diff --git a/tests/unittest/test_pr_description_output_core.py b/tests/unittest/test_pr_description_output_core.py index 9d7a6a6352..421979932f 100644 --- a/tests/unittest/test_pr_description_output_core.py +++ b/tests/unittest/test_pr_description_output_core.py @@ -190,7 +190,7 @@ def test_html_comment_guard_prevents_type_replacement(self, mock_get_settings): body_in = "\npr_agent:type stays raw" obj = self._obj_with_user_description(body_in, {"title": "AI", "type": "Bug fix"}) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() # Guard present -> the plain marker is NOT replaced. assert "pr_agent:type stays raw" in body @@ -204,7 +204,7 @@ def test_plain_summary_marker_is_replaced(self, mock_get_settings): {"title": "AI", "description": "Adds caching layer."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Adds caching layer." in body assert "pr_agent:summary" not in body @@ -217,7 +217,7 @@ def test_summary_marker_is_removed_when_description_disabled(self, mock_get_sett {"title": "AI", "description": "Adds caching layer."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Adds caching layer." not in body assert "pr_agent:summary" not in body @@ -230,7 +230,7 @@ def test_html_comment_summary_marker_is_removed_when_description_disabled(self, {"title": "AI", "description": "Adds caching layer."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Adds caching layer." not in body assert "pr_agent:summary" not in body @@ -243,7 +243,7 @@ def test_generated_by_header_prefixes_replacements(self, mock_get_settings): {"title": "AI", "type": "Bug fix", "description": "Fix bug."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "### 🤖 Generated by PR Agent at deadbeef" in body # Header appears for both replaced markers. @@ -257,7 +257,7 @@ def test_list_type_is_joined_with_comma(self, mock_get_settings): {"title": "AI", "type": ["Bug fix", "Refactor"]}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Bug fix, Refactor" in body @@ -270,7 +270,7 @@ def test_diagram_marker_replaces_both_plain_and_html_comment(self, mock_get_sett {"title": "AI", "changes_diagram": diagram}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() # Both forms are substituted with the diagram. assert body.count("```mermaid") == 2 @@ -285,7 +285,7 @@ def test_title_falls_back_when_generate_ai_title_disabled(self, mock_get_setting {"title": "AI Title", "description": "x"}, ) - title, _, _, _ = obj._prepare_pr_answer_with_markers() + title, _ = obj._prepare_pr_answer_with_markers() assert title == "Original title" @@ -314,7 +314,7 @@ def test_labels_removed_when_provider_supports_get_labels(self, mock_get_setting obj = self._obj({"title": "t", "labels": ["bug"], "description": "d"}) obj.git_provider.is_supported.side_effect = lambda cap: cap in {"gfm_markdown", "get_labels"} - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() # The Labels section is suppressed for providers with native label support. assert "Labels" not in body @@ -325,7 +325,7 @@ def test_type_section_removed_when_disabled(self, mock_get_settings): mock_get_settings.return_value = _settings(enable_pr_type=False) obj = self._obj({"title": "t", "type": "Bug fix", "description": "d"}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert "PR Type" not in body assert "Bug fix" not in body @@ -335,7 +335,7 @@ def test_description_section_removed_when_disabled(self, mock_get_settings): mock_get_settings.return_value = _settings(enable_pr_description=False) obj = self._obj({"title": "t", "type": "Bug fix", "description": "AI summary"}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert "AI summary" not in body # The other sections are untouched. @@ -353,7 +353,7 @@ def test_diagram_and_walkthrough_survive_a_disabled_description(self, mock_get_s "pr_files": [{"filename": "app.py", "changes_title": "c", "label": "enhancement"}], }) - _, body, walkthrough, _ = obj._prepare_pr_answer() + _, body, walkthrough = obj._prepare_pr_answer() assert "AI summary" not in body assert f"### {PRDescriptionHeader.DIAGRAM_WALKTHROUGH.value}" in body @@ -367,7 +367,7 @@ def test_description_kept_by_default(self, mock_get_settings): mock_get_settings.return_value = _settings() obj = self._obj({"title": "t", "description": "AI summary"}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert "AI summary" in body @@ -379,7 +379,7 @@ def test_description_list_value_is_joined_and_bullets_spaced(self, mock_get_sett "description": "Intro\n- one\n- two", }) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() # Bullet readability: single newline before "-" becomes double newline. assert "Intro\n\n- one\n\n- two" in body @@ -390,7 +390,7 @@ def test_diagram_section_uses_header_enum(self, mock_get_settings): diagram = "\n```mermaid\ngraph LR\nA --> B\n```" obj = self._obj({"title": "t", "description": "d", "changes_diagram": diagram}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert f"### {PRDescriptionHeader.DIAGRAM_WALKTHROUGH.value}" in body assert "```mermaid" in body @@ -400,7 +400,7 @@ def test_title_uses_vars_title_when_data_has_no_title(self, mock_get_settings): mock_get_settings.return_value = _settings(generate_ai_title=False) obj = self._obj({"description": "d"}) - title, _, _, _ = obj._prepare_pr_answer() + title, _, _ = obj._prepare_pr_answer() assert title == "Original title" @@ -423,10 +423,9 @@ def test_non_gfm_provider_skips_table_rendering(self, mock_get_settings): obj = self._obj(gfm=False) value = {"backend": [("src/app.py", "Add cache", "Adds a bounded cache.")]} - body, comments = obj.process_pr_files_prediction("PRE", value) + body = obj.process_pr_files_prediction("PRE", value) assert body == "PRE" - assert comments == [] @patch("pr_agent.tools.pr_description.get_settings") def test_gfm_provider_emits_table_with_file_row(self, mock_get_settings): @@ -437,14 +436,13 @@ def test_gfm_provider_emits_table_with_file_row(self, mock_get_settings): obj = self._obj(gfm=True, diff_files=[diff]) value = {"backend": [("src/app.py", "Add cache", "Adds a bounded cache.")]} - body, comments = obj.process_pr_files_prediction("", value) + body = obj.process_pr_files_prediction("", value) assert body.startswith("
    ") assert body.rstrip().endswith("
    ") assert "Backend" in body assert "app.py" in body assert "+5/-2" in body - assert comments == [] @patch("pr_agent.tools.pr_description.get_settings") def test_adaptive_collapsible_triggers_above_threshold(self, mock_get_settings): @@ -458,7 +456,7 @@ def test_adaptive_collapsible_triggers_above_threshold(self, mock_get_settings): ] } - body, _ = obj.process_pr_files_prediction("", value) + body = obj.process_pr_files_prediction("", value) assert "
    2 files" in body @@ -480,7 +478,7 @@ def test_walkthrough_table_round_trips_through_process_description(self, mock_ge obj.git_provider.get_line_link.return_value = "https://example/blob/main/src/app.py#L1" value = {"backend": [("src/app.py", "Add cache", "Adds a bounded cache.")]} - table, _ = obj.process_pr_files_prediction("", value) + table = obj.process_pr_files_prediction("", value) full_description = ( "Some intro text.\n\n___\n\n" diff --git a/tests/unittest/test_run_details_wiring.py b/tests/unittest/test_run_details_wiring.py index b9b5684830..5424034eab 100644 --- a/tests/unittest/test_run_details_wiring.py +++ b/tests/unittest/test_run_details_wiring.py @@ -137,7 +137,7 @@ async def test_pr_description_appends_run_details_only_when_enabled(monkeypatch) description.git_provider.is_supported.side_effect = lambda cap: cap == "gfm_markdown" description._prepare_data = MagicMock() - description._prepare_pr_answer = MagicMock(return_value=("AI title", "Base description body", "", [])) + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Base description body", "")) monkeypatch.setattr("pr_agent.tools.pr_description.init_run_details", _seeded_init_run_details) monkeypatch.setattr("pr_agent.tools.pr_description.extract_and_cache_pr_tickets", _noop_async) From f7ef91326ed1711f88df4393222ba39db01fe57d Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 11 Sep 2026 04:50:09 -0500 Subject: [PATCH 060/238] fix(help): allow question mode without an OpenAI key (#3262) Co-authored-by: IsmaelMartinez --- pr_agent/tools/pr_help_message.py | 8 -- .../test_pr_help_message_rendering.py | 97 ++++++++++++++++++- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 987c8b7608..03a9206ae6 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -85,14 +85,6 @@ async def run(self): if self.question_str: get_logger().info(f'Answering a PR question about the PR {self.git_provider.pr_url} ') - if not get_settings().get('openai.key'): - if get_settings().config.publish_output: - self.git_provider.publish_comment( - "The `Help` tool chat feature requires an OpenAI API key for calculating embeddings") - else: - get_logger().error("The `Help` tool chat feature requires an OpenAI API key for calculating embeddings") - return - # current path docs_path= Path(__file__).parent.parent.parent / 'docs' / 'docs' # get all the 'md' files inside docs_path and its subdirectories diff --git a/tests/unittest/test_pr_help_message_rendering.py b/tests/unittest/test_pr_help_message_rendering.py index 879a95550e..1134268020 100644 --- a/tests/unittest/test_pr_help_message_rendering.py +++ b/tests/unittest/test_pr_help_message_rendering.py @@ -1,4 +1,4 @@ -"""The /help walkthrough picks its rendering from provider capabilities, not provider classes.""" +"""Regression tests for provider-independent /help behavior.""" import pytest @@ -7,6 +7,7 @@ from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider from pr_agent.git_providers.github_provider import GithubProvider from pr_agent.git_providers.gitlab_provider import GitLabProvider +from pr_agent.tools import pr_help_message as pr_help_message_module from pr_agent.tools.pr_help_message import PRHelpMessage from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -19,6 +20,7 @@ class StubProvider: """A provider that is none of the concrete classes the tool used to branch on.""" def __init__(self, gfm_markdown: bool, markdown_tables: bool = False, checkbox_commands: bool = False): + self.pr_url = "https://example.com/org/repo/pull/1" self._gfm_markdown = gfm_markdown self._markdown_tables = markdown_tables self._checkbox_commands = checkbox_commands @@ -37,6 +39,25 @@ def publish_comment(self, pr_comment: str, is_temporary: bool = False): self.published.append(pr_comment) +class StubAiHandler: + def __init__(self, response=None, error=None): + self.response = response + self.error = error + self.calls = [] + + async def chat_completion(self, *, model, temperature, system, user): + self.calls.append({"model": model, "system": system, "user": user}) + if self.error: + raise self.error + return self.response, "stop" + + +class StubTokenHandler: + @staticmethod + def count_tokens(text): + return len(text) + + async def run_walkthrough(provider) -> str: tool = PRHelpMessage.__new__(PRHelpMessage) tool.git_provider = provider @@ -47,6 +68,26 @@ async def run_walkthrough(provider) -> str: return provider.published[0] +def build_question_tool(tmp_path, monkeypatch, handler): + package_root = tmp_path / "package" + docs_path = package_root / "docs" / "docs" + review_doc = docs_path / "tools" / "review.md" + review_doc.parent.mkdir(parents=True) + review_doc.write_text("# Automatic review\n\nEnable automatic review in the repository settings.", encoding="utf-8") + + source_path = package_root / "pr_agent" / "tools" / "pr_help_message.py" + monkeypatch.setattr(pr_help_message_module, "Path", lambda _: source_path) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = StubProvider(gfm_markdown=True) + tool.ai_handler = handler + tool.question_str = "How do I configure automatic reviews?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + tool.token_handler = StubTokenHandler() + return tool + + @pytest.fixture def published_output(): snapshot = snapshot_settings(["config.publish_output", "config.disable_checkboxes"]) @@ -55,6 +96,60 @@ def published_output(): restore_settings(snapshot) +@pytest.fixture +def non_openai_question_settings(): + keys = [ + "config.model", + "config.fallback_models", + "model_routing.enable", + "openai.key", + "openai.deployment_id", + ] + snapshot = snapshot_settings(keys) + settings = get_settings() + settings.set("config.model", "anthropic/claude-3-5-sonnet-20240620") + settings.set("config.fallback_models", []) + settings.set("model_routing.enable", False) + settings.set("openai.key", None) + settings.set("openai.deployment_id", None) + yield + restore_settings(snapshot) + + +async def test_question_reaches_configured_handler_without_openai_key( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert [call["model"] for call in handler.calls] == ["anthropic/claude-3-5-sonnet-20240620"] + assert tool.question_str in handler.calls[0]["user"] + assert "Enable automatic review in the repository settings." in handler.calls[0]["user"] + assert "Enable automatic review in the repository settings." in tool.git_provider.published[0] + assert "requires an OpenAI API key" not in tool.git_provider.published[0] + + +async def test_question_uses_configured_handler_error_path_without_openai_key( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + handler = StubAiHandler(error=RuntimeError("provider credentials missing")) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert [call["model"] for call in handler.calls] == ["anthropic/claude-3-5-sonnet-20240620"] + assert tool.git_provider.published == [] + + @pytest.mark.parametrize( "capabilities, expected, unexpected", [ From 9933a0125c616572a6771943ac214b727bfe6c6b Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:36:09 +0545 Subject: [PATCH 061/238] docs: document custom CA and self-signed certificate configuration (#3275) --- docs/docs/installation/gitlab.md | 1 + docs/docs/summary.md | 1 + .../custom_ca_and_self_signed_certificates.md | 46 +++++++++++++++++++ docs/mkdocs.yml | 1 + 4 files changed, 49 insertions(+) create mode 100644 docs/docs/usage-guide/custom_ca_and_self_signed_certificates.md diff --git a/docs/docs/installation/gitlab.md b/docs/docs/installation/gitlab.md index d4ccbdae2d..447bfb5687 100644 --- a/docs/docs/installation/gitlab.md +++ b/docs/docs/installation/gitlab.md @@ -88,6 +88,7 @@ Note that if your base branches are not protected, don't set the variables as `p > **Note**: The `gitlab__SSL_VERIFY` environment variable can be used to specify the path to a custom CA certificate bundle for SSL verification. GitLab exposes the `$CI_SERVER_TLS_CA_FILE` variable, which points to the custom CA certificate file configured in your GitLab instance. > Alternatively, SSL verification can be disabled entirely by setting `gitlab__SSL_VERIFY=false`, although this is not recommended. +> This setting affects only the GitLab API client. For certificate issues with LLM calls or git clone operations, see [Custom CA and Self-Signed Certificates](../usage-guide/custom_ca_and_self_signed_certificates.md). ## Run a GitLab webhook server diff --git a/docs/docs/summary.md b/docs/docs/summary.md index 3e9acff231..568c943baf 100644 --- a/docs/docs/summary.md +++ b/docs/docs/summary.md @@ -27,6 +27,7 @@ * [Changing a Model](usage-guide/changing_a_model.md) * [Extending PR-Agent](usage-guide/extending_pr_agent.md) * [Additional Configurations](usage-guide/additional_configurations.md) +* [Custom CA and Self-Signed Certificates](usage-guide/custom_ca_and_self_signed_certificates.md) * [Plain-Diff Mode](usage-guide/plain_diff_mode.md) * [Local Git Provider](usage-guide/local_git_provider.md) * [Frequently Asked Questions](faq/index.md) diff --git a/docs/docs/usage-guide/custom_ca_and_self_signed_certificates.md b/docs/docs/usage-guide/custom_ca_and_self_signed_certificates.md new file mode 100644 index 0000000000..cf2cd88f25 --- /dev/null +++ b/docs/docs/usage-guide/custom_ca_and_self_signed_certificates.md @@ -0,0 +1,46 @@ +## Custom CA and self-signed certificates + +When PR-Agent runs behind a corporate TLS-inspecting proxy or against servers using self-signed certificates, HTTPS calls to the LLM provider and git operations can fail with `certificate verify failed` errors. The fix requires two pieces: telling the HTTP clients which CA bundle to trust, and making LiteLLM use a transport that honours that bundle. + +### Environment variables for CA trust + +PR-Agent reads three environment variables when building the git clone environment, in the order below. The first one that is **set** wins, and if it points to a file that does not exist PR-Agent logs a warning and configures no bundle for git rather than falling through to the next variable: + +| Variable | Scope | Notes | +|---|---|---| +| `SSL_CERT_FILE` | git clone and Python `requests` | Preferred. Used by most Python SSL contexts. | +| `REQUESTS_CA_BUNDLE` | git clone and Python `requests` | Fallback. Honoured by the `requests` library directly. | +| `GIT_SSL_CAINFO` | git clone only | Last resort. Used by git when the other two are absent. | + +If more than one variable is set and points to a different file, PR-Agent logs a warning and picks one according to the precedence above. All three variables should point to the same PEM file in practice. + +Example (runner environment or shell profile): + +```bash +export SSL_CERT_FILE=/etc/ssl/certs/corporate-ca-bundle.crt +``` + +These variables are read from the process environment, so they must be exported in the runner or shell profile; a `.secrets.toml` `[env]` section does **not** reach `os.environ` and cannot set them. + +### LiteLLM transport fallback + +By default LiteLLM uses aiohttp for HTTP calls, and aiohttp does not honour the standard CA environment variables. Setting `litellm.disable_aiohttp` makes LiteLLM fall back to httpx, which reads `SSL_CERT_FILE` (and `SSL_CERT_DIR`). httpx does not read `REQUESTS_CA_BUNDLE`, so the LLM call needs `SSL_CERT_FILE` specifically. + +```toml +[litellm] +disable_aiohttp = true +``` + +This setting is read once when the LiteLLM handler initialises, so it must be present before the process starts (runner environment or `.secrets.toml`, not a runtime override). + +### Scope of `gitlab.ssl_verify` + +The `gitlab.ssl_verify` setting (or `gitlab__SSL_VERIFY` environment variable) is passed only to the python-gitlab client that talks to the GitLab API. It does **not** affect the LLM call, git clone operations, or any other HTTPS client in the process. If you are seeing certificate errors from LiteLLM or git clone, use the environment variables above instead. + +### Putting it all together + +For a runner behind a corporate proxy with a custom CA: + +1. Export `SSL_CERT_FILE` pointing to your CA bundle. `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` cover git clone only. +2. Set `litellm.disable_aiohttp = true` in your configuration. +3. If you also use the GitLab API, keep `gitlab.ssl_verify` pointed at the same bundle for the python-gitlab client. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 66d386a373..cc501eb20b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -27,6 +27,7 @@ nav: - Changing a Model: 'usage-guide/changing_a_model.md' - Extending PR-Agent: 'usage-guide/extending_pr_agent.md' - Additional Configurations: 'usage-guide/additional_configurations.md' + - Custom CA and Self-Signed Certificates: 'usage-guide/custom_ca_and_self_signed_certificates.md' - Plain-Diff Mode: 'usage-guide/plain_diff_mode.md' - Local Git Provider: 'usage-guide/local_git_provider.md' - Frequently Asked Questions: 'faq/index.md' From ae2fcce4b32ea6ef595fa17a62ae65404b5126c8 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:37:35 +0545 Subject: [PATCH 062/238] docs(similar_issue): refresh page with experimental status and backend caveats (#3277) --- docs/docs/tools/similar_issues.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/docs/tools/similar_issues.md b/docs/docs/tools/similar_issues.md index ccc69600cf..5d66719ed9 100644 --- a/docs/docs/tools/similar_issues.md +++ b/docs/docs/tools/similar_issues.md @@ -1,5 +1,7 @@ ## Overview +> **Note**: `/similar_issue` is an **experimental** feature. It works only on GitHub, carries a disproportionately large share of the project's dependency and configuration surface for a single-provider tool, and is therefore excluded from the v1 stability guarantees. Its backends are not equally exercised: the lancedb flow has no tests of its own, and no backend is tested against its real driver. + The similar issue tool retrieves the most similar issues to the current issue. It can be invoked manually by commenting on any PR: @@ -50,6 +52,12 @@ gcp-starter pod tier is no longer supported. created. An existing index is opened by name and is never recreated, so moving an existing deployment to the new configuration does not lose the stored vectors. +!!! note "Backend coverage is uneven" + + No backend is exercised against its real driver: the `similar-issue` dependency group is + not installed in CI, so the pinecone tests run against a faked module and the qdrant tests + never construct a client. The lancedb flow has no tests of its own at all. + !!! note "Default vector database" `vectordb` defaults to `lancedb`, which works with no external credentials. To use @@ -75,7 +83,7 @@ vectordb = "qdrant" You can get a free managed Qdrant instance from [Qdrant Cloud](https://cloud.qdrant.io/). -Qdrant points are stored in a collection named `codium-ai-pr-agent-issues-v2`. +Qdrant points are stored in a collection named `codium-ai-pr-agent-issues-v2`, derived by appending a `-v2` suffix to the shared index name (`codium-ai-pr-agent-issues`). The suffix is an implementation detail of the Qdrant backend only; pinecone and lancedb use the unsuffixed name. !!! note "Upgrading an index created before the point-id fix" From 0818a5f717c794bb815ed6062ab32db554f4ab53 Mon Sep 17 00:00:00 2001 From: Lukas Buck <103962359+L4XB@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:52:55 +0200 Subject: [PATCH 063/238] fix(codecommit): cap comment bodies at the PostCommentForPullRequest limit (#3280) --- pr_agent/algo/review_finding_state.py | 15 ++++ pr_agent/git_providers/codecommit_provider.py | 26 ++++++- tests/unittest/test_codecommit_provider.py | 70 +++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/pr_agent/algo/review_finding_state.py b/pr_agent/algo/review_finding_state.py index 154cbed16c..768ecafe88 100644 --- a/pr_agent/algo/review_finding_state.py +++ b/pr_agent/algo/review_finding_state.py @@ -155,6 +155,21 @@ def parse_review_state(comment_body: str) -> ParsedReviewState: return ParsedReviewState(state, present=True, valid=True) +def split_review_state_marker(comment_body: str) -> tuple[str, str]: + """Split a comment into its human text and its hidden state marker. + + Returns ``(body, marker)``; ``marker`` is ``""`` when the comment carries no + complete marker. Providers that cap comment length use this to keep the + marker intact while truncating only the human text. + """ + raw = comment_body or "" + match = _STATE_MARKER_RE.search(raw) + if match is None: + return raw, "" + body = (raw[: match.start()] + raw[match.end():]).rstrip() + return body, match.group(0) + + def serialize_review_state(state: Mapping[str, Any]) -> str: """Serialize state deterministically so repeated updates are diffable.""" if not _is_valid_state(state): diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index acc9c58d9d..c2878d4789 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse from pr_agent.algo.language_handler import is_valid_file +from pr_agent.algo.review_finding_state import split_review_state_marker from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.git_providers.codecommit_client import CodeCommitClient @@ -69,6 +70,15 @@ class CodeCommitProvider(GitProvider): This class implements the GitProvider interface for AWS CodeCommit repositories. """ + # PostCommentForPullRequest / UpdateComment reject a body above 10,240 + # characters and raise instead of degrading (#3272). Every outgoing body + # goes through _prepare_comment_body, which caps it AFTER the newline + # doubling and after any persistent-comment header has been added, so the + # cap is measured on what CodeCommit actually receives. Class-level, like + # the other providers' max_comment_length, minus the truncation marker + # limit_output_characters appends. + max_comment_length = 10240 - len("...") + def __init__(self, pr_url: Optional[str] = None, incremental: Optional[bool] = False): self.codecommit_client = CodeCommitClient() self.aws_client = None @@ -677,10 +687,20 @@ def _persistent_body_for_target( updated_anchor = f"{identity_marker}\n\n{update_message}" return pr_comment.replace(identity_marker, updated_anchor, 1) - @staticmethod - def _prepare_comment_body(pr_comment: str) -> str: + def _prepare_comment_body(self, pr_comment: str) -> str: pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment) - return CodeCommitProvider._add_additional_newlines(pr_comment) + body, marker = split_review_state_marker(pr_comment) + body = CodeCommitProvider._add_additional_newlines(body) + if not marker: + return self.limit_output_characters(body, self.max_comment_length) + # The persistent review state is a hidden marker at the end of the body. + # The reviewer sizes it before the newline doubling above, so the doubled + # body can overrun the cap; truncate only the human text and keep the + # marker whole, otherwise the next run cannot parse the state. + budget = self.max_comment_length - len(marker) - 2 + if budget <= 0: + return marker[: self.max_comment_length] + return f"{self.limit_output_characters(body, budget)}\n\n{marker}" @staticmethod def _extract_issue_comments(comment_data: dict): diff --git a/tests/unittest/test_codecommit_provider.py b/tests/unittest/test_codecommit_provider.py index b5a47ac863..117cbe5b93 100644 --- a/tests/unittest/test_codecommit_provider.py +++ b/tests/unittest/test_codecommit_provider.py @@ -272,6 +272,76 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): ("two.py", "before two\n", "after two\n"), ] + def test_prepare_comment_body_caps_at_codecommit_limit(self): + # PostCommentForPullRequest rejects bodies above 10,240 characters and + # publish_comment raises instead of degrading (#3272). The cap must be + # measured AFTER the newline doubling, which grows the body. + provider = self._make_persistent_provider() + body = "\n".join(["x" * 100] * 120) # 12,099 chars before doubling + + prepared = provider._prepare_comment_body(body) + + assert len(prepared) <= 10240 + assert prepared.endswith("...") + assert "\n\n" in prepared + + def test_prepare_comment_body_keeps_review_state_marker_parseable(self): + # The reviewer budgets the hidden state marker before the newline + # doubling; once doubled, the body can exceed the cap. Only the human + # text may be truncated, or the next run cannot read the state. + from pr_agent.algo.review_finding_state import append_review_state, parse_review_state + + provider = self._make_persistent_provider() + state = { + "schema_version": 1, + "last_run": {"commit": "abc123"}, + "findings": [ + {"finding_id": f"f{i}", "state": "ACTIVE", "path": "a.py", "body": "x" * 40} + for i in range(20) + ], + } + review = "\n".join(["r" * 24] * 400) + body = append_review_state(review, state, max_chars=10240 - 3) + assert len(body) <= 10240 - 3 + + prepared = provider._prepare_comment_body(body) + + assert len(prepared) <= 10240 + parsed = parse_review_state(prepared) + assert parsed.valid + assert [f["finding_id"] for f in parsed.state["findings"]] == [f"f{i}" for i in range(20)] + assert prepared.rstrip().endswith("-->") + assert "..." in prepared + + def test_class_docstring_survives_the_comment_limit_attribute(self): + assert CodeCommitProvider.__doc__ is not None + assert "CodeCommit" in CodeCommitProvider.__doc__ + + def test_prepare_comment_body_leaves_short_comment_alone(self): + provider = self._make_persistent_provider() + + assert provider._prepare_comment_body("line one\nline two") == "line one\n\nline two" + + def test_publish_comment_sends_capped_body(self): + provider = self._make_persistent_provider() + provider.codecommit_client.publish_comment.return_value = {"comment": {}} + + provider.publish_comment("\n".join(["y" * 100] * 120)) + + sent = provider.codecommit_client.publish_comment.call_args.kwargs["comment"] + assert len(sent) <= 10240 + assert sent.endswith("...") + + def test_edit_comment_sends_capped_body(self): + provider = self._make_persistent_provider() + provider.codecommit_client.update_comment.return_value = {"comment": {}} + + provider.edit_comment({"id": "comment-1"}, "\n".join(["z" * 100] * 120)) + + sent = provider.codecommit_client.update_comment.call_args.args[1] + assert len(sent) <= 10240 + assert sent.endswith("...") + def test_publish_comment_uses_every_pull_request_target(self): provider = object.__new__(CodeCommitProvider) provider.repo_name = "source-repository" From f3d8a5b22d17279e0fe805fbc1ef19548c5c12b9 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 11 Sep 2026 04:53:10 -0500 Subject: [PATCH 064/238] fix: point runtime documentation links to current site (#3281) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- .../algo/ai_handlers/litellm_ai_handler.py | 2 +- pr_agent/git_providers/utils.py | 2 +- pr_agent/servers/help.py | 30 ++++++++-------- pr_agent/tools/pr_description.py | 4 +-- pr_agent/tools/pr_help_message.py | 34 +++++++++++++------ tests/unittest/test_command_descriptions.py | 20 +++++++++++ tests/unittest/test_git_provider_utils.py | 2 ++ .../test_litellm_chat_completion_core.py | 25 ++++++++++++++ .../test_pr_description_help_rendering.py | 2 ++ .../unittest/test_pr_help_message_fallback.py | 4 ++- .../test_pr_help_message_rendering.py | 26 ++++++++++++++ 11 files changed, 121 insertions(+), 30 deletions(-) diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 4ced0b9133..204c477918 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -3780,7 +3780,7 @@ async def _chat_completion_with_retry( timeout=_IMAGE_HEAD_TIMEOUT_SECONDS, ) if r.status_code == 404: - error_msg = "The image link is not [alive](img_path).\nPlease repost the original image as a comment, and send the question again with 'quote reply' (see [instructions](https://pr-agent-docs.codium.ai/tools/ask/#ask-on-images-using-the-pr-code-as-context))." + error_msg = "The image link is not [alive](img_path).\nPlease repost the original image as a comment, and send the question again with 'quote reply' (see [instructions](https://docs.pr-agent.ai/tools/ask/#ask-on-images))." get_logger().error(error_msg) return f"{error_msg}", "error" except Exception as e: diff --git a/pr_agent/git_providers/utils.py b/pr_agent/git_providers/utils.py index 50337df987..892ac58ed4 100644 --- a/pr_agent/git_providers/utils.py +++ b/pr_agent/git_providers/utils.py @@ -393,7 +393,7 @@ def handle_configurations_errors(config_errors, git_provider): header = f"❌ **PR-Agent failed to apply '{config_type}' repo settings**" body = ( f"{header}\n\nThe configuration file needs to be a valid " - "[TOML](https://qodo-merge-docs.qodo.ai/usage-guide/configuration_options/), please fix it.\n\n" + "[TOML](https://docs.pr-agent.ai/usage-guide/configuration_options/), please fix it.\n\n" ) body += f"___\n\n**Error message:**\n`{err_message}`\n\n" if config_type == "global": diff --git a/pr_agent/servers/help.py b/pr_agent/servers/help.py index aa44601f78..058216f33c 100644 --- a/pr_agent/servers/help.py +++ b/pr_agent/servers/help.py @@ -11,7 +11,7 @@ def get_general_commands_text(): "> - **/update_changelog**: Update the changelog based on the PR's contents. \n" \ "> - **/add_docs**: Generate docstring for new components introduced in the PR. \n" \ "> - **/generate_labels**: Generate labels for the PR based on the PR's contents. \n\n" \ - ">See the [tools guide](https://pr-agent-docs.codium.ai/tools/) for more details.\n" \ + ">See the [tools guide](https://docs.pr-agent.ai/tools/) for more details.\n" \ ">To list the possible configuration parameters, add a **/config** comment. \n" return commands_text @@ -25,14 +25,14 @@ def get_general_bot_help_text(): def get_review_usage_guide(): output ="**Overview:**\n" output += (f"{COMMAND_DESCRIPTIONS['review']} " - "More feedback can be [added](https://pr-agent-docs.codium.ai/tools/review/#general-configurations) by configuring the tool.\n\n" - "The tool can be triggered [automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on any PR.\n") + "More feedback can be [added](https://docs.pr-agent.ai/tools/review/#configuration-options) by configuring the tool.\n\n" + "The tool can be triggered [automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on any PR.\n") output +="""\ - When commenting, to edit [configurations](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L23) related to the review tool (`pr_reviewer` section), use the following template: ``` /review --pr_reviewer.some_config1=... --pr_reviewer.some_config2=... ``` -- With a [configuration file](https://pr-agent-docs.codium.ai/usage-guide/configuration_options/), use the following template: +- With a [configuration file](https://docs.pr-agent.ai/usage-guide/configuration_options/), use the following template: ``` [pr_reviewer] some_config1=... @@ -40,7 +40,7 @@ def get_review_usage_guide(): ``` """ - output += "\n\nSee the review [usage page](https://pr-agent-docs.codium.ai/tools/review/) for a comprehensive guide on using this tool.\n\n" + output += "\n\nSee the review [usage page](https://docs.pr-agent.ai/tools/review/) for a comprehensive guide on using this tool.\n\n" return output @@ -50,14 +50,14 @@ def get_review_usage_guide(): def get_describe_usage_guide(): output = "**Overview:**\n" output += f"{COMMAND_DESCRIPTIONS['describe']} " - output += "The tool can be triggered [automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on a PR.\n" + output += "The tool can be triggered [automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on a PR.\n" output += """\ When commenting, to edit [configurations](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L46) related to the describe tool (`pr_description` section), use the following template: ``` /describe --pr_description.some_config1=... --pr_description.some_config2=... ``` -With a [configuration file](https://pr-agent-docs.codium.ai/usage-guide/configuration_options/), use the following template: +With a [configuration file](https://docs.pr-agent.ai/usage-guide/configuration_options/), use the following template: ``` [pr_description] some_config1=... @@ -69,7 +69,7 @@ def get_describe_usage_guide(): # automation output += "
    Enabling\\disabling automation
    \n\n" output += """\ -- When you first install the app, the [default mode](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) for the describe tool is: +- When you first install the app, the [default mode](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) for the describe tool is: ``` pr_commands = ["/describe", ...] ``` @@ -95,7 +95,7 @@ def get_describe_usage_guide(): output += """\ The default labels of the `describe` tool are quite generic: [`Bug fix`, `Tests`, `Enhancement`, `Documentation`, `Other`]. -If you specify [custom labels](https://pr-agent-docs.codium.ai/tools/describe/#handle-custom-labels-from-the-repos-labels-page) in the repo's labels page or via configuration file, you can get tailored labels for your use cases. +If you specify [custom labels](https://docs.pr-agent.ai/tools/describe/#handle-custom-labels-from-the-repos-labels-page) in the repo's labels page or via configuration file, you can get tailored labels for your use cases. Examples for custom labels: - `Main topic:performance` - pr_agent:The main topic of this PR is performance - `New endpoint` - pr_agent:A new endpoint was added in this PR @@ -137,7 +137,7 @@ def get_describe_usage_guide(): output += "" - output += "\n\nSee the [describe usage](https://pr-agent-docs.codium.ai/tools/describe/) page for a comprehensive guide on using this tool.\n\n" + output += "\n\nSee the [describe usage](https://docs.pr-agent.ai/tools/describe/) page for a comprehensive guide on using this tool.\n\n" return output @@ -163,7 +163,7 @@ def get_ask_usage_guide(): # # output += "" - output += "\n\nSee the [ask usage](https://pr-agent-docs.codium.ai/tools/ask/) page for a comprehensive guide on using this tool.\n\n" + output += "\n\nSee the [ask usage](https://docs.pr-agent.ai/tools/ask/) page for a comprehensive guide on using this tool.\n\n" return output @@ -172,7 +172,7 @@ def get_ask_usage_guide(): def get_improve_usage_guide(): output = "**Overview:**\n" output += f"{COMMAND_DESCRIPTIONS['improve']} " - output += "The tool can be triggered [automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on a PR.\n" + output += "The tool can be triggered [automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on a PR.\n" output += """\ - When commenting, to edit [configurations](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L78) related to the improve tool (`pr_code_suggestions` section), use the following template: @@ -180,7 +180,7 @@ def get_improve_usage_guide(): /improve --pr_code_suggestions.some_config1=... --pr_code_suggestions.some_config2=... ``` -- With a [configuration file](https://pr-agent-docs.codium.ai/usage-guide/configuration_options/), use the following template: +- With a [configuration file](https://docs.pr-agent.ai/usage-guide/configuration_options/), use the following template: ``` [pr_code_suggestions] @@ -190,7 +190,7 @@ def get_improve_usage_guide(): """ - output += "\n\nSee the improve [usage page](https://pr-agent-docs.codium.ai/tools/improve/) for a comprehensive guide on using this tool.\n\n" + output += "\n\nSee the improve [usage page](https://docs.pr-agent.ai/tools/improve/) for a comprehensive guide on using this tool.\n\n" return output @@ -205,5 +205,5 @@ def get_help_docs_usage_guide(): /help_docs "..." ``` """ - output += "\n\nSee the [help_docs usage](https://pr-agent-docs.codium.ai/tools/help_docs/) page for a comprehensive guide on using this tool.\n\n" + output += "\n\nSee the [help_docs usage](https://docs.pr-agent.ai/tools/help_docs/) page for a comprehensive guide on using this tool.\n\n" return output diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index a9c19610e6..971fb5de80 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -161,12 +161,12 @@ async def run(self): if self.git_provider.supports_inline_help_footer(): pr_body += ('\n\n___\n\n>
    Need help?
  • Type /help how to ... ' 'in the comments thread for any questions about PR-Agent usage.
  • Check out the ' - 'documentation ' + 'documentation ' 'for more information.
  • ') else: # bullets separated by
    , for providers whose footer cannot inline a list pr_body += ("\n\n___\n\n
    Need help?- Type /help how to ... in the comments " "thread for any questions about PR-Agent usage.
    - Check out the " - "documentation for more information.
    ") + "documentation for more information.
    ") # elif get_settings().pr_description.enable_help_comment: # pr_body += '\n\n___\n\n> 💡 **PR-Agent usage**: Comment `/help "your question"` on any pull request to receive relevant information' diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 03a9206ae6..75942c2f31 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -15,6 +15,8 @@ from pr_agent.git_providers import get_git_provider_with_context from pr_agent.log import get_logger +DOCS_SITE_URL = "https://docs.pr-agent.ai" + class PRHelpMessage: def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler, return_as_string=False): @@ -79,6 +81,20 @@ def format_markdown_header(self, header: str) -> str: get_logger().exception("Error while formatting markdown header", artifacts={'header': header}) return "" + def format_docs_url(self, file_name: str, header: str) -> str: + relative_path = file_name.strip().lstrip('/').removesuffix('.md') + if relative_path == 'index': + relative_path = '' + elif relative_path.endswith('/index'): + relative_path = relative_path.removesuffix('index') + elif relative_path: + relative_path += '/' + + docs_url = f"{DOCS_SITE_URL}/{relative_path}" + if str(header).strip(): + docs_url += f"#{self.format_markdown_header(header)}" + return docs_url + async def run(self): try: @@ -150,14 +166,12 @@ async def run(self): answer_str += f"### Question: \n{self.question_str}\n\n" answer_str += f"### Answer:\n{response_str.strip()}\n\n" answer_str += "#### Relevant Sources:\n\n" - base_path = "https://qodo-merge-docs.qodo.ai/" for section in relevant_sections: - file = section.get('file_name').strip().removesuffix('.md') - if str(section['relevant_section_header_string']).strip(): - markdown_header = self.format_markdown_header(section['relevant_section_header_string']) - answer_str += f"> - {base_path}{file}#{markdown_header}\n" - else: - answer_str += f"> - {base_path}{file}\n" + docs_url = self.format_docs_url( + section.get('file_name'), + section['relevant_section_header_string'], + ) + answer_str += f"> - {docs_url}\n" # publish the answer @@ -179,7 +193,7 @@ async def run(self): pr_comment = "## PR Agent Walkthrough 🤖\n\n" pr_comment += "Welcome to the PR Agent, an AI-powered tool for automated pull request analysis, feedback, suggestions and more.""" pr_comment += "\n\nHere is a list of tools you can use to interact with the PR Agent:\n" - base_path = "https://pr-agent-docs.codium.ai/tools" + base_path = f"{DOCS_SITE_URL}/tools" tool_names = [] tool_names.append(f"[DESCRIBE]({base_path}/describe/)") @@ -225,7 +239,7 @@ async def run(self): for i in range(len(tool_names)): pr_comment += f"\n\n\n{tool_names[i]}\n{descriptions[i]}\n\n\n{checkbox_list[i]}\n" pr_comment += "\n\n" - pr_comment += """\n\n(1) Note that each tool can be [triggered automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) when a new PR is opened, or called manually by [commenting on a PR](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#online-usage).""" + pr_comment += """\n\n(1) Note that each tool can be [triggered automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) when a new PR is opened, or called manually by [commenting on a PR](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#online-usage).""" pr_comment += """\n\n(2) Tools marked with [*] require additional parameters to be passed. For example, to invoke the `/ask` tool, you need to comment on a PR: `/ask ""`. See the relevant documentation for each tool for more details.""" elif not supports_gfm_markdown: # only basic commands, in a plain markdown table (e.g. BBDC) @@ -235,7 +249,7 @@ async def run(self): for i in range(len(tool_names)): pr_comment += f"\n\n\n{tool_names[i]}{commands[i]}{descriptions[i]}" pr_comment += "\n\n" - pr_comment += """\n\nNote that each tool can be [invoked automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/) when a new PR is opened, or called manually by [commenting on a PR](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#online-usage).""" + pr_comment += """\n\nNote that each tool can be [invoked automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/) when a new PR is opened, or called manually by [commenting on a PR](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#online-usage).""" if get_settings().config.publish_output: self.git_provider.publish_comment(pr_comment) diff --git a/tests/unittest/test_command_descriptions.py b/tests/unittest/test_command_descriptions.py index 8916f8f2d6..32cd3c8519 100644 --- a/tests/unittest/test_command_descriptions.py +++ b/tests/unittest/test_command_descriptions.py @@ -9,6 +9,8 @@ from pr_agent.tools.pr_help_message import PRHelpMessage REPO_ROOT = Path(__file__).resolve().parents[2] +CURRENT_DOCS_URL = "https://docs.pr-agent.ai" +RETIRED_DOCS_HOSTS = ("qodo-merge-docs.qodo.ai", "pr-agent-docs.codium.ai") def test_cli_and_bot_help_use_canonical_command_descriptions(): @@ -32,6 +34,24 @@ def test_tool_usage_guides_use_canonical_command_descriptions(usage_guide, comma assert COMMAND_DESCRIPTIONS[command] in usage_guide() +def test_runtime_help_uses_current_documentation_links(): + help_text = "\n".join( + [ + HelpMessage.get_general_commands_text(), + HelpMessage.get_general_bot_help_text(), + HelpMessage.get_review_usage_guide(), + HelpMessage.get_describe_usage_guide(), + HelpMessage.get_ask_usage_guide(), + HelpMessage.get_improve_usage_guide(), + HelpMessage.get_help_docs_usage_guide(), + ] + ) + + assert f"{CURRENT_DOCS_URL}/tools/review/#configuration-options" in help_text + assert f"{CURRENT_DOCS_URL}/tools/ask/" in help_text + assert not any(host in help_text for host in RETIRED_DOCS_HOSTS) + + @pytest.mark.parametrize("command", COMMAND_DESCRIPTIONS) def test_tool_docs_use_canonical_command_descriptions(command): tool_docs = REPO_ROOT / "docs" / "docs" / "tools" / f"{command}.md" diff --git a/tests/unittest/test_git_provider_utils.py b/tests/unittest/test_git_provider_utils.py index 4048f73335..6dc7105463 100644 --- a/tests/unittest/test_git_provider_utils.py +++ b/tests/unittest/test_git_provider_utils.py @@ -112,6 +112,8 @@ def test_handle_configurations_errors_uses_persistent_comment_when_supported(): assert comment["final_update_message"] is False assert "PR-Agent failed to apply 'local' repo settings" in comment["body"] assert "Invalid value" in comment["body"] + assert "https://docs.pr-agent.ai/usage-guide/configuration_options/" in comment["body"] + assert "qodo-merge-docs.qodo.ai" not in comment["body"] assert "```toml\n[config]\nmodel =\n```" in comment["body"] assert "
    Configuration content:" in comment["body"] diff --git a/tests/unittest/test_litellm_chat_completion_core.py b/tests/unittest/test_litellm_chat_completion_core.py index 21f5d25bad..ab0bebec3b 100644 --- a/tests/unittest/test_litellm_chat_completion_core.py +++ b/tests/unittest/test_litellm_chat_completion_core.py @@ -117,6 +117,31 @@ def fake_head(url, **kwargs): } +@pytest.mark.asyncio +async def test_chat_completion_dead_image_uses_current_help_link(monkeypatch): + monkeypatch.setattr(litellm_handler, "get_settings", FakeSettings) + monkeypatch.setattr( + litellm_handler.requests, + "head", + lambda *args, **kwargs: SimpleNamespace(status_code=404), + ) + + with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", new_callable=AsyncMock) as mock_call: + handler = litellm_handler.LiteLLMAIHandler() + + response, finish_reason = await handler.chat_completion( + model="gpt-4o", + system="sys", + user="usr", + img_path="https://example.test/missing.png", + ) + + assert "https://docs.pr-agent.ai/tools/ask/#ask-on-images" in response + assert "pr-agent-docs.codium.ai" not in response + assert finish_reason == "error" + mock_call.assert_not_awaited() + + @pytest.mark.asyncio @pytest.mark.parametrize( ("model", "expected_model_id"), diff --git a/tests/unittest/test_pr_description_help_rendering.py b/tests/unittest/test_pr_description_help_rendering.py index ef72c3e4bb..110459312f 100644 --- a/tests/unittest/test_pr_description_help_rendering.py +++ b/tests/unittest/test_pr_description_help_rendering.py @@ -105,6 +105,8 @@ async def test_description_help_rendering_follows_html_list_capability( assert provider.html_lists_queries == 1 assert expected in body assert unexpected not in body + assert "https://docs.pr-agent.ai/usage-guide/" in body + assert "qodo-merge-docs.qodo.ai" not in body if not html_lists: assert PLAIN_BREAK_MARKER in body diff --git a/tests/unittest/test_pr_help_message_fallback.py b/tests/unittest/test_pr_help_message_fallback.py index 01877804cb..9b898dc9cf 100644 --- a/tests/unittest/test_pr_help_message_fallback.py +++ b/tests/unittest/test_pr_help_message_fallback.py @@ -90,7 +90,9 @@ async def test_primary_failure_uses_backup_answer(help_tool): assert tool.question_str in backup_prompt assert "Enable automatic review in the repository settings." in backup_prompt tool.git_provider.publish_comment.assert_called_once() - assert "### Answer:\nEnable automatic review" in tool.git_provider.publish_comment.call_args.args[0] + published_comment = tool.git_provider.publish_comment.call_args.args[0] + assert "### Answer:\nEnable automatic review" in published_comment + assert "> - https://docs.pr-agent.ai/tools/review/#automatic-review" in published_comment assert details.model_used == BACKUP assert details.fallback_used is True assert get_settings().get("openai.deployment_id") == "primary-deployment" diff --git a/tests/unittest/test_pr_help_message_rendering.py b/tests/unittest/test_pr_help_message_rendering.py index 1134268020..1cf2ecc3cf 100644 --- a/tests/unittest/test_pr_help_message_rendering.py +++ b/tests/unittest/test_pr_help_message_rendering.py @@ -14,6 +14,8 @@ INTERACTIVE_MARKER = "Trigger Interactively" PLAIN_TABLE_MARKER = "| Tool | Description |" UNSUPPORTED_MARKER = "requires gfm markdown" +CURRENT_DOCS_URL = "https://docs.pr-agent.ai" +RETIRED_DOCS_HOSTS = ("qodo-merge-docs.qodo.ai", "pr-agent-docs.codium.ai") class StubProvider: @@ -174,6 +176,30 @@ async def test_checkboxes_stay_disabled_by_configuration(published_output): assert "" in comment +@pytest.mark.parametrize("checkbox_commands", [False, True], ids=["without-checkboxes", "with-checkboxes"]) +async def test_walkthrough_uses_current_documentation_site(published_output, checkbox_commands): + comment = await run_walkthrough(StubProvider(gfm_markdown=True, checkbox_commands=checkbox_commands)) + + assert (INTERACTIVE_MARKER in comment) is checkbox_commands + assert f"{CURRENT_DOCS_URL}/tools/review/" in comment + assert f"{CURRENT_DOCS_URL}/usage-guide/automations_and_usage/" in comment + assert not any(host in comment for host in RETIRED_DOCS_HOSTS) + + +@pytest.mark.parametrize( + ("file_name", "header", "expected"), + [ + ("/index.md", "", f"{CURRENT_DOCS_URL}/"), + ("/tools/review.md", "Automatic review", f"{CURRENT_DOCS_URL}/tools/review/#automatic-review"), + ("/faq/index.md", "Frequently asked questions", f"{CURRENT_DOCS_URL}/faq/#frequently-asked-questions"), + ], +) +def test_question_source_urls_use_canonical_documentation_paths(file_name, header, expected): + tool = PRHelpMessage.__new__(PRHelpMessage) + + assert tool.format_docs_url(file_name, header) == expected + + @pytest.mark.parametrize( "provider_class, checkbox_commands, markdown_tables", [ From 2b308ad2b51ce588a3e5a81d784644053b737924 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 11 Sep 2026 04:53:26 -0500 Subject: [PATCH 065/238] fix(cli): validate output artifact options (#3289) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- docs/docs/usage-guide/plain_diff_mode.md | 21 +- pr_agent/cli.py | 68 +++- tests/unittest/test_cli_plain_diff_mode.py | 361 ++++++++++++++++++++- 3 files changed, 423 insertions(+), 27 deletions(-) diff --git a/docs/docs/usage-guide/plain_diff_mode.md b/docs/docs/usage-guide/plain_diff_mode.md index d754e771a9..560dd59c68 100644 --- a/docs/docs/usage-guide/plain_diff_mode.md +++ b/docs/docs/usage-guide/plain_diff_mode.md @@ -26,20 +26,28 @@ python -m pr_agent.cli --diff-file changes.diff --output review.md review |---|---| | `--stdin` | Read a unified diff from stdin | | `--diff-file ` | Read a unified diff from a file | -| `--output ` | Write the result to a file in addition to stdout | -| `--json-output ` | Write the parsed review and token usage to a JSON file (`review` command) | +| `--output ` | Write Markdown produced by a compatible Plain Diff command to a file in addition to stdout | +| `--json-output ` | Write the parsed review and token usage to a JSON file (`review` or `review_pr` only) | `--stdin` and `--diff-file` are mutually exclusive. At least one must be provided to enter plain-diff mode; omitting both falls back to the normal `--pr_url` flow. +Output options must appear before the command. For example, use +`--stdin --output result.md review`, not `--stdin review --output result.md`. ### Supported commands -`review`, `improve`, `describe`, and `ask` are supported. Because there is no hosting +`review`, `improve`, `describe`, and `ask` are supported, along with their +`review_pr`, `improve_code`, `describe_pr`, and `ask_question` aliases. Because there is no hosting platform to push to, `improve` renders its code suggestions as a single markdown document to stdout (and to `--output`, if given) instead of as committable inline suggestions. Commands that require live platform interaction (such as `update_changelog` or `similar_issue`) are not meaningful in this mode. +Existing command variants that publish Markdown without additional platform +state (`auto_review`, `config`, `settings`, and `help`) also honor `--output`. +`answer` does not: it requires issue-comment history, which is unavailable when +the input is a standalone diff. + ## How it works 1. **Diff parsing** — the unified diff is parsed locally into per-file patch objects. @@ -55,9 +63,10 @@ suggestions. Commands that require live platform interaction (such as for that file. The review still runs; it simply has less context. 4. **Output** — the result is written to stdout. If `--output ` is given, it is - also written to that file (UTF-8, overwritten on each run). For `review`, if - `--json-output ` is given, the parsed review plus a `usage` object with the - run's accumulated token counts is also written to that file as JSON. + also written to that file (UTF-8, overwritten on each run). For `review` or + `review_pr`, if `--json-output ` is given, the parsed review plus a + `usage` object with the run's accumulated token counts is also written to that + file as JSON. No platform token, no PR URL, and no internet access are required for the diff processing step itself. An LLM API key is still needed unless you configure a local model. diff --git a/pr_agent/cli.py b/pr_agent/cli.py index e13c41891c..6bf432c95f 100644 --- a/pr_agent/cli.py +++ b/pr_agent/cli.py @@ -18,6 +18,61 @@ log_level = os.environ.get("LOG_LEVEL", "INFO") setup_logger(log_level) +_PLAIN_DIFF_MARKDOWN_COMMANDS = frozenset({ + "review", "review_pr", "auto_review", + "describe", "describe_pr", + "improve", "improve_code", + "ask", "ask_question", + "config", "settings", "help", +}) +_PLAIN_DIFF_JSON_COMMANDS = frozenset({"review", "review_pr"}) +_OUTPUT_OPTIONS = ("--output", "--json-output") + + +def _resolve_output_option(parser, arg): + option_text = arg.partition("=")[0] + if option_text in _OUTPUT_OPTIONS: + return option_text + if not option_text.startswith("--") or not parser.allow_abbrev: + return None + + # argparse has no public API for resolving one option spelling. Reuse its + # own option table so misplaced options follow the parser's abbreviation + # rules and future ambiguous prefixes are left untouched. + matches = parser._get_option_tuples(option_text) + if len(matches) == 1 and matches[0][1] in _OUTPUT_OPTIONS: + return matches[0][1] + return None + + +def _validate_output_options(parser, args, diff_mode): + for arg in getattr(args, "rest", []): + option = _resolve_output_option(parser, arg) + if option: + parser.error( + f"{option} must appear before the command " + f"(for example: --stdin {option} result review)" + ) + + output = getattr(args, "output", None) + json_output = getattr(args, "json_output", None) + for option, value in (("--output", output), ("--json-output", json_output)): + if value is not None and not value: + parser.error(f"{option} requires a non-empty path") + + command = args.command.lstrip("/").lower() + if output is not None: + if not diff_mode: + parser.error("--output is only supported in plain-diff mode (--stdin or --diff-file)") + if command not in _PLAIN_DIFF_MARKDOWN_COMMANDS: + parser.error(f"--output is not supported for plain-diff command '{command}'") + + if json_output is not None: + if not diff_mode: + parser.error("--json-output is only supported in plain-diff mode (--stdin or --diff-file)") + if command not in _PLAIN_DIFF_JSON_COMMANDS: + parser.error("--json-output is only supported for plain-diff review commands (review or review_pr)") + def set_parser(): parser = argparse.ArgumentParser(description='AI based pull request analyzer', usage= @@ -71,9 +126,11 @@ def set_parser(): parser.add_argument("--stdin", action="store_true", default=False, help="Read a unified diff from stdin (plain-diff local mode)") parser.add_argument("--output", dest="output", type=str, default=None, - help="Write the result to this file (in addition to stdout)") + help=("Write Plain Diff Markdown output to this file " + "(place before the command)")) parser.add_argument("--json-output", dest="json_output", type=str, default=None, - help="Write the parsed review and token usage to this JSON file") + help=("Write a Plain Diff review and token usage to this JSON file " + "(place before the review command)")) parser.add_argument('command', type=str, help='The', choices=commands, default='review') parser.add_argument('rest', nargs=argparse.REMAINDER, default=[]) return parser @@ -93,11 +150,10 @@ def run(inargs=None, args=None): if not args: args = parser.parse_args(inargs) diff_mode = getattr(args, "stdin", False) or getattr(args, "diff_file", None) - if getattr(args, "json_output", None) and not diff_mode: - parser.error("--json-output is only supported in plain-diff mode (--stdin or --diff-file)") + if diff_mode and args.stdin and args.diff_file: + parser.error("--stdin and --diff-file are mutually exclusive") + _validate_output_options(parser, args, diff_mode) if diff_mode: - if args.stdin and args.diff_file: - parser.error("--stdin and --diff-file are mutually exclusive") if args.diff_file: try: with open(args.diff_file, "r", encoding="utf-8") as fh: diff --git a/tests/unittest/test_cli_plain_diff_mode.py b/tests/unittest/test_cli_plain_diff_mode.py index 2011d1f1d8..b3a9a057e4 100644 --- a/tests/unittest/test_cli_plain_diff_mode.py +++ b/tests/unittest/test_cli_plain_diff_mode.py @@ -1,13 +1,119 @@ +import builtins +import io + import pytest -from pr_agent.cli import run, set_parser +from pr_agent.cli import _resolve_output_option, commands, run, set_parser from pr_agent.config_loader import get_settings # Keys run() mutates on the process-wide settings singleton, directly or via the # diff-mode CLI path. Snapshotted and restored around every test (autouse) so # state never leaks, even when run() sets keys the test never touches itself. -_SETTINGS_KEYS = ["plain_diff.content", "plain_diff.output_path", "plain_diff.json_output_path", - "config.git_provider", "config.publish_output"] +_SETTINGS_KEYS = [ + "plain_diff.content", + "plain_diff.output_path", + "plain_diff.json_output_path", + "config.git_provider", + "config.publish_output", + "config.cli_mode", + "config.config_branch", + "config.extra_config_url", +] + +_DIFF = ( + "diff --git a/foo.py b/foo.py\n" + "index 1111111..2222222 100644\n" + "--- a/foo.py\n" + "+++ b/foo.py\n" + "@@ -1,3 +1,3 @@\n" + " line1\n-line2\n+line2-changed\n line3\n" +) +_REVIEW_MARKER = "plain-diff auto-review output" +_CANNED_REVIEW = f"""\ +review: + estimated_effort_to_review_[1-5]: '1' + score: '90' + relevant_tests: 'No' + key_issues_to_review: + - relevant_file: foo.py + issue_header: '{_REVIEW_MARKER}' + issue_content: 'Verify the changed line.' + start_line: 2 + end_line: 2 + security_concerns: 'No' +""" +_OUTPUT_OPTION_PREFIXES = [ + (option[:length], option) + for option in ("--output", "--json-output") + for length in range(3, len(option) + 1) +] + +_MARKDOWN_COMMANDS = [ + "review", + "review_pr", + "auto_review", + "describe", + "describe_pr", + "improve", + "improve_code", + "ask", + "ask_question", + "config", + "settings", + "help", +] + +_NON_MARKDOWN_COMMANDS = [ + "answer", + "ask_line", + "update_changelog", + "similar_issue", + "add_docs", + "generate_labels", +] + + +def test_output_command_matrix_covers_cli_commands(): + assert set(_MARKDOWN_COMMANDS) | set(_NON_MARKDOWN_COMMANDS) == set(commands) + assert set(_MARKDOWN_COMMANDS).isdisjoint(_NON_MARKDOWN_COMMANDS) + + +def _rejecting_input_args(input_mode, monkeypatch): + class UnreadableStdin: + def read(self): + pytest.fail("stdin must not be read for invalid output options") + + if input_mode == "stdin": + monkeypatch.setattr("sys.stdin", UnreadableStdin()) + return ["--stdin"] + + original_open = builtins.open + + def guarded_open(path, *args, **kwargs): + if path == "changes.diff": + pytest.fail("the diff file must not be opened for invalid output options") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr("builtins.open", guarded_open) + return ["--diff-file", "changes.diff"] + + +def _valid_input_args(input_mode, monkeypatch, tmp_path): + if input_mode == "stdin": + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + return ["--stdin"] + + diff_file = tmp_path / "changes.diff" + diff_file.write_text(_DIFF, encoding="utf-8") + return ["--diff-file", str(diff_file)] + + +def _fail_if_agent_constructed(monkeypatch): + class NeverAgent: + def __init__(self): + pytest.fail("PRAgent must not be constructed for invalid output options") + + monkeypatch.setattr("pr_agent.cli.PRAgent", NeverAgent) @pytest.fixture(autouse=True) @@ -43,6 +149,28 @@ def test_parser_stdin_flag(): assert args.stdin is True +@pytest.mark.parametrize( + ("option_spelling", "option"), + _OUTPUT_OPTION_PREFIXES, +) +def test_parser_accepts_unambiguous_output_abbreviations_before_command( + option_spelling, option, +): + parser = set_parser() + args = parser.parse_args(["--stdin", option_spelling, "result", "review"]) + + destination = option[2:].replace("-", "_") + assert getattr(args, destination) == "result" + + +def test_misplaced_output_abbreviation_must_be_unambiguous(): + parser = set_parser() + parser.add_argument("--outcome") + + assert _resolve_output_option(parser, "--out=value") is None + assert _resolve_output_option(parser, "--output=value") == "--output" + + def test_missing_diff_file_fails_fast(tmp_path, capsys): """A non-existent --diff-file must exit cleanly via parser.error (SystemExit) with a clear message, not crash with an uncaught OSError traceback.""" @@ -56,27 +184,232 @@ def test_missing_diff_file_fails_fast(tmp_path, capsys): def test_json_output_outside_diff_mode_fails_fast(capsys): """Reject --json-output in hosted-provider mode via parser.error instead of silently dropping the explicitly requested artifact.""" - with pytest.raises(SystemExit): + with pytest.raises(SystemExit) as exc_info: run(inargs=["--pr_url", "https://example/pr/1", "--json-output", "out.json", "review"]) + assert exc_info.value.code == 2 err = capsys.readouterr().err assert "--json-output is only supported in plain-diff mode" in err -_DIFF = ( - "diff --git a/foo.py b/foo.py\n" - "index 1111111..2222222 100644\n" - "--- a/foo.py\n" - "+++ b/foo.py\n" - "@@ -1,3 +1,3 @@\n" - " line1\n-line2\n+line2-changed\n line3\n" +@pytest.mark.parametrize( + "target_args", + [ + ["--pr_url", "https://example/pr/1"], + ["--issue_url", "https://example/issue/1"], + [], + ], ) +def test_markdown_output_outside_plain_diff_fails_before_dispatch(target_args, monkeypatch, capsys): + _fail_if_agent_constructed(monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*target_args, "--output", "out.md", "review"]) + + assert exc_info.value.code == 2 + assert "--output is only supported in plain-diff mode" in capsys.readouterr().err + + +def test_markdown_output_rejects_local_git_mode_before_dispatch(monkeypatch, capsys): + _fail_if_agent_constructed(monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[ + "--pr_url", "main", + "--output", "out.md", + "review", + "--config.git_provider=local", + ]) + + assert exc_info.value.code == 2 + assert "--output is only supported in plain-diff mode" in capsys.readouterr().err + + +@pytest.mark.parametrize("option", ["--output", "--json-output"]) +@pytest.mark.parametrize("spelling", ["equals", "separate"]) +@pytest.mark.parametrize("mode", ["hosted", "stdin"]) +def test_empty_output_paths_fail_before_input_or_dispatch( + option, spelling, mode, monkeypatch, capsys, +): + _fail_if_agent_constructed(monkeypatch) + target_args = ["--pr_url", "https://example/pr/1"] + if mode == "stdin": + target_args = _rejecting_input_args("stdin", monkeypatch) + option_args = [f"{option}="] if spelling == "equals" else [option, ""] + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*target_args, *option_args, "review"]) + + assert exc_info.value.code == 2 + assert f"{option} requires a non-empty path" in capsys.readouterr().err + + +@pytest.mark.parametrize("command", _NON_MARKDOWN_COMMANDS) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_markdown_output_rejects_unsupported_plain_diff_commands_before_read( + command, input_mode, monkeypatch, capsys, +): + input_args = _rejecting_input_args(input_mode, monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*input_args, "--output", "out.md", command]) + + assert exc_info.value.code == 2 + assert "--output is not supported for" in capsys.readouterr().err + + +@pytest.mark.parametrize("command", _MARKDOWN_COMMANDS) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_markdown_output_accepts_compatible_plain_diff_commands( + command, input_mode, monkeypatch, tmp_path, +): + captured = {} + + class FakeAgent: + async def handle_request(self, target, request, notify=None): + captured["target"] = target + captured["request"] = request + captured["output_path"] = get_settings().plain_diff.output_path + return True + + monkeypatch.setattr("pr_agent.cli.PRAgent", FakeAgent) + input_args = _valid_input_args(input_mode, monkeypatch, tmp_path) + + output = tmp_path / "result.md" + run(inargs=[*input_args, "--output", str(output), command]) + + assert captured == { + "target": "local_diff", + "request": [command], + "output_path": str(output), + } + + +@pytest.mark.parametrize("command", ["config", "settings", "help"]) +def test_compatible_non_model_commands_write_markdown_artifacts( + command, monkeypatch, tmp_path, +): + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + output = tmp_path / f"{command}.md" + + run(inargs=["--stdin", "--output", str(output), command]) + + assert output.read_text(encoding="utf-8").strip() + + +def test_auto_review_writes_markdown_artifact_with_stubbed_model( + monkeypatch, tmp_path, +): + async def fake_chat_completion(self, model, system, user, temperature=0.2, **kwargs): + return _CANNED_REVIEW, "stop" + + monkeypatch.setattr( + "pr_agent.algo.ai_handlers.litellm_ai_handler.LiteLLMAIHandler.chat_completion", + fake_chat_completion, + ) + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + output = tmp_path / "auto-review.md" + + run(inargs=["--stdin", "--output", str(output), "auto_review"]) + + assert _REVIEW_MARKER in output.read_text(encoding="utf-8") + + +def test_answer_output_is_rejected_without_creating_artifact(monkeypatch, tmp_path, capsys): + input_args = _rejecting_input_args("stdin", monkeypatch) + output = tmp_path / "answer.md" + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*input_args, "--output", str(output), "answer"]) + + assert exc_info.value.code == 2 + assert "--output is not supported for" in capsys.readouterr().err + assert not output.exists() + + +@pytest.mark.parametrize("command", ["review", "review_pr"]) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_json_output_accepts_review_aliases(command, input_mode, monkeypatch, tmp_path): + captured = {} + + class FakeAgent: + async def handle_request(self, target, request, notify=None): + captured["request"] = request + captured["json_output_path"] = get_settings().plain_diff.json_output_path + return True + + monkeypatch.setattr("pr_agent.cli.PRAgent", FakeAgent) + input_args = _valid_input_args(input_mode, monkeypatch, tmp_path) + + output = tmp_path / "review.json" + run(inargs=[*input_args, "--json-output", str(output), command]) + + assert captured == { + "request": [command], + "json_output_path": str(output), + } + + +@pytest.mark.parametrize("command", [*_NON_MARKDOWN_COMMANDS, *_MARKDOWN_COMMANDS[2:]]) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_json_output_rejects_non_review_commands_before_read( + command, input_mode, monkeypatch, capsys, +): + input_args = _rejecting_input_args(input_mode, monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*input_args, "--json-output", "out.json", command]) + + assert exc_info.value.code == 2 + assert "--json-output is only supported for plain-diff review commands" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("option_spelling", "option"), + _OUTPUT_OPTION_PREFIXES, +) +@pytest.mark.parametrize("value_form", ["separate", "equals"]) +def test_output_options_after_command_fail_before_read( + option_spelling, option, value_form, monkeypatch, capsys, +): + class UnreadableStdin: + def read(self): + pytest.fail("stdin must not be read for misplaced output options") + + monkeypatch.setattr("sys.stdin", UnreadableStdin()) + value = "out.json" if option == "--json-output" else "out.md" + trailing_args = ( + [option_spelling, value] + if value_form == "separate" + else [f"{option_spelling}={value}"] + ) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=["--stdin", "review", *trailing_args]) + + assert exc_info.value.code == 2 + err = capsys.readouterr().err + assert f"{option} must appear before the command" in err + + +def test_near_match_after_command_remains_a_tool_argument(monkeypatch): + captured = {} + + class FakeAgent: + async def handle_request(self, target, request, notify=None): + captured["request"] = request + return True + + monkeypatch.setattr("pr_agent.cli.PRAgent", FakeAgent) + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + + run(inargs=["--stdin", "review", "--output-format=markdown"]) + + assert captured["request"] == ["review", "--output-format=markdown"] def test_diff_mode_forces_publish_output(cfg, monkeypatch): """Diff mode must force config.publish_output=True so stdout/--output is never suppressed by a config/env that disabled publishing.""" - import io - cfg("config.publish_output", False) captured = {} @@ -92,8 +425,6 @@ async def handle_request(self, target, request, notify=None): def test_diff_mode_sets_json_output_path(cfg, monkeypatch, tmp_path): - import io - captured = {} class FakeAgent: From 4d209abfac6f92ec6a76f3a71143818315dad8a5 Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Fri, 11 Sep 2026 17:53:41 +0800 Subject: [PATCH 066/238] perf(review): reuse prepared diff chunks (#3291) Signed-off-by: hwan <3373484735@qq.com> --- pr_agent/algo/pr_processing.py | 252 +++++++++++++----- pr_agent/tools/pr_reviewer.py | 40 ++- tests/unittest/test_pr_processing_core.py | 134 ++++++++++ .../test_review_large_diff_chunking.py | 24 ++ 4 files changed, 364 insertions(+), 86 deletions(-) diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index 68943aee66..885e32ead5 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -1,6 +1,7 @@ from __future__ import annotations import traceback +from dataclasses import dataclass from typing import Callable, List, Tuple from pr_agent.algo.git_patch_processing import ( @@ -29,6 +30,23 @@ MAX_EXTRA_LINES = 10 +@dataclass +class PreparedPRDiff: + """The single-call diff and compressed file data prepared for one model attempt. + + The compressed file data is request-scoped. It is only reused by a caller that keeps the + same token handler and model, so fallback attempts still rebuild their model-specific budget. + """ + + diff: str + remaining_files_list: list + file_dict: dict | None = None + files_by_name: dict | None = None + model: str | None = None + add_line_numbers_to_hunks: bool = False + token_handler: TokenHandler | None = None + + def cap_and_log_extra_lines(value, direction) -> int: try: value = int(value) @@ -47,7 +65,8 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False, large_pr_handling=False, - return_remaining_files=False): + return_remaining_files=False, + return_prepared=False): if disable_extra_lines: PATCH_EXTRA_LINES_BEFORE = 0 PATCH_EXTRA_LINES_AFTER = 0 @@ -76,7 +95,12 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model): get_logger().info(f"Tokens: {total_tokens}, total tokens under limit: {get_max_tokens(model)}, " f"returning full diff.") - return "\n".join(patches_extended) + full_diff = "\n".join(patches_extended) + if return_prepared: + return PreparedPRDiff(full_diff, [], model=model, + add_line_numbers_to_hunks=add_line_numbers_to_hunks, + token_handler=token_handler) + return full_diff # if we are over the limit, start pruning (If we got here, we will not extend the patches with extra lines) get_logger().info(f"Tokens: {total_tokens}, total tokens over limit: {get_max_tokens(model)}, " @@ -139,6 +163,21 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, get_logger().debug(f"After pruning, added_list_str: {added_list_str}, modified_list_str: {modified_list_str}, " f"deleted_list_str: {deleted_list_str}") + if return_prepared: + files_by_name = { + file.filename: file + for language in pr_languages + for file in language["files"] + } + return PreparedPRDiff( + final_diff, + remaining_files_list, + file_dict, + files_by_name, + model=model, + add_line_numbers_to_hunks=add_line_numbers_to_hunks, + token_handler=token_handler, + ) if not return_remaining_files: return final_diff else: @@ -163,6 +202,107 @@ def get_pr_diff_multiple_patchs(git_provider: GitProvider, token_handler: TokenH return patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list +def _pack_pr_multi_diffs(file_dict: dict, + token_handler: TokenHandler, + model: str, + max_calls: int, + return_remaining_files: bool): + patches = [] + final_diff_list = [] + files_in_patches = set() + total_tokens = token_handler.prompt_tokens + call_number = 1 + + for filename, data in file_dict.items(): + if call_number > max_calls: + if get_verbosity_level() >= 2: + get_logger().info(f"Reached max calls ({max_calls})") + break + + patch = data["patch"] + new_patch_tokens = data["tokens"] + + if patch and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: + if get_settings().config.get("large_patch_policy", "skip") == "skip": + get_logger().warning(f"Patch too large, skipping: {filename}") + continue + if get_settings().config.get("large_patch_policy") == "clip": + delta_tokens = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - token_handler.prompt_tokens + patch_clipped = clip_tokens(patch, delta_tokens, delete_last_line=True, + num_input_tokens=new_patch_tokens) + new_patch_tokens = token_handler.count_tokens(patch_clipped) + if patch_clipped and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: + get_logger().warning(f"Patch too large, skipping: {filename}") + continue + get_logger().info(f"Clipped large patch for file: {filename}") + patch = patch_clipped + else: + get_logger().warning(f"Patch too large, skipping: {filename}") + continue + + if patch and (total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD): + final_diff_list.append("\n".join(patches)) + patches = [] + total_tokens = token_handler.prompt_tokens + call_number += 1 + if call_number > max_calls: + if get_verbosity_level() >= 2: + get_logger().info(f"Reached max calls ({max_calls})") + break + if get_verbosity_level() >= 2: + get_logger().info(f"Call number: {call_number}") + + if patch: + patches.append(patch) + files_in_patches.add(filename) + total_tokens += new_patch_tokens + if get_verbosity_level() >= 2: + get_logger().info(f"Tokens: {total_tokens}, last filename: {filename}") + + if patches: + final_diff_list.append("\n".join(patches).strip()) + + if not return_remaining_files: + return final_diff_list + + remaining_files_list = [ + filename for filename in file_dict + if filename not in files_in_patches + ] + return final_diff_list, remaining_files_list + + +def _get_pr_multi_diffs_from_prepared(prepared_diff: PreparedPRDiff, + token_handler: TokenHandler, + model: str, + max_calls: int, + return_remaining_files: bool): + """Pack already transformed file patches without repeating preparation work. + + ``get_pr_diff`` and the review chunking path use the same model-specific token handler and + line-number format. Reusing its compressed file dictionary preserves the existing packing + and large-patch policy while avoiding a second provider fetch, patch conversion, and token + count for every file. + """ + file_dict = {} + for filename, data in (prepared_diff.file_dict or {}).items(): + patch = data["patch"] + tokens = data["tokens"] + file = (prepared_diff.files_by_name or {}).get(filename) + if file and file.ai_file_summary and get_settings().get("config.enable_ai_metadata", False): + patch = add_ai_summary_top_patch(file, patch) + tokens = token_handler.count_tokens(patch) + file_dict[filename] = {**data, "patch": patch, "tokens": tokens} + + return _pack_pr_multi_diffs( + file_dict, + token_handler, + model, + max_calls, + return_remaining_files, + ) + + def pr_generate_extended_diff(pr_languages: list, token_handler: TokenHandler, add_line_numbers_to_hunks: bool, @@ -396,7 +536,8 @@ def get_pr_multi_diffs(git_provider: GitProvider, model: str, max_calls: int = 5, add_line_numbers: bool = True, - return_remaining_files: bool = False): + return_remaining_files: bool = False, + prepared_diff: PreparedPRDiff | None = None): """ Retrieves the diff files from a Git provider, sorts them by main language, and generates patches for each file. The patches are split into multiple groups based on the maximum number of tokens allowed for the given model. @@ -409,12 +550,30 @@ def get_pr_multi_diffs(git_provider: GitProvider, return_remaining_files (bool, optional): Also return the files the token budget left out, in the same shape as `get_pr_diff`. Files without a patch, and delete-only files, are not reported: nothing was omitted for them. Defaults to False. + prepared_diff (PreparedPRDiff, optional): Reuse compressed file data prepared by a preceding + `get_pr_diff` call for the same model attempt. Defaults to None. Returns: List[str]: A list of final diff strings, split into multiple groups based on the maximum number of tokens allowed for the given model. With `return_remaining_files`, a tuple of that list and the list of omitted file names. """ + if ( + prepared_diff is not None + and prepared_diff.file_dict is not None + and prepared_diff.model == model + and add_line_numbers + and prepared_diff.add_line_numbers_to_hunks == add_line_numbers + and prepared_diff.token_handler is token_handler + ): + return _get_pr_multi_diffs_from_prepared( + prepared_diff, + token_handler, + model, + max_calls, + return_remaining_files, + ) + diff_files = git_provider.get_diff_files() # Sort files by main language @@ -443,17 +602,11 @@ def get_pr_multi_diffs(git_provider: GitProvider, for lang in pr_languages: sorted_files.extend(sorted(lang['files'], key=lambda x: x.tokens, reverse=True)) - patches = [] - final_diff_list = [] - files_in_patches = set() # files that made it into a chunk - total_tokens = token_handler.prompt_tokens - call_number = 1 + # Build the same transformed file dictionary used by the prepared path, preserving the + # descending token order established above. The shared packer then owns chunk boundaries, + # large-patch policy, and remaining-file tracking for both paths. + file_dict = {} for file in sorted_files: - if call_number > max_calls: - if get_verbosity_level() >= 2: - get_logger().info(f"Reached max calls ({max_calls})") - break - original_file_content_str = file.base_file new_file_content_str = file.head_file patch = file.patch @@ -475,66 +628,19 @@ def get_pr_multi_diffs(git_provider: GitProvider, if file.ai_file_summary and get_settings().get("config.enable_ai_metadata", False): patch = add_ai_summary_top_patch(file, patch) new_patch_tokens = token_handler.count_tokens(patch) - - if patch and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens( - model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - if get_settings().config.get('large_patch_policy', 'skip') == 'skip': - get_logger().warning(f"Patch too large, skipping: {file.filename}") - continue - elif get_settings().config.get('large_patch_policy') == 'clip': - delta_tokens = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - token_handler.prompt_tokens - patch_clipped = clip_tokens(patch, delta_tokens, delete_last_line=True, num_input_tokens=new_patch_tokens) - new_patch_tokens = token_handler.count_tokens(patch_clipped) - if patch_clipped and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens( - model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - get_logger().warning(f"Patch too large, skipping: {file.filename}") - continue - else: - get_logger().info(f"Clipped large patch for file: {file.filename}") - patch = patch_clipped - else: - get_logger().warning(f"Patch too large, skipping: {file.filename}") - continue - - if patch and (total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD): - final_diff = "\n".join(patches) - final_diff_list.append(final_diff) - patches = [] - total_tokens = token_handler.prompt_tokens - call_number += 1 - if call_number > max_calls: # avoid creating new patches - if get_verbosity_level() >= 2: - get_logger().info(f"Reached max calls ({max_calls})") - break - if get_verbosity_level() >= 2: - get_logger().info(f"Call number: {call_number}") - - if patch: - patches.append(patch) - files_in_patches.add(file.filename) - total_tokens += new_patch_tokens - if get_verbosity_level() >= 2: - get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}") - - # Add the last chunk - if patches: - final_diff = "\n".join(patches) - final_diff_list.append(final_diff.strip()) - - if not return_remaining_files: - return final_diff_list - - remaining_files_list = [] - for file in sorted_files: - if file.filename in files_in_patches or file.filename in remaining_files_list: - continue - # a file with no patch, or with a delete-only patch, has nothing to review: - # the token budget is not what kept it out of the diff - if not file.patch or handle_patch_deletions(file.patch, file.base_file, file.head_file, - file.filename, file.edit_type) is None: - continue - remaining_files_list.append(file.filename) - return final_diff_list, remaining_files_list + file_dict[file.filename] = { + 'patch': patch, + 'tokens': new_patch_tokens, + 'edit_type': file.edit_type, + } + + return _pack_pr_multi_diffs( + file_dict, + token_handler, + model, + max_calls, + return_remaining_files, + ) def add_ai_metadata_to_diff_files(git_provider, pr_description_files): diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index ac0701f278..4b2ade90df 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -18,6 +18,7 @@ key_issue_location_fingerprint, ) from pr_agent.algo.pr_processing import ( + PreparedPRDiff, add_ai_metadata_to_diff_files, get_pr_diff, get_pr_multi_diffs, @@ -761,21 +762,28 @@ async def _prepare_prediction(self, model: str) -> None: get_settings().pr_review_prompt.user, model, ) - output = get_pr_diff(self.git_provider, - self.token_handler, - model, - add_line_numbers_to_hunks=True, - disable_extra_lines=False, - return_remaining_files=True,) - if isinstance(output, tuple): + chunking_enabled = get_settings().pr_reviewer.get("enable_large_pr_chunking", False) + diff_kwargs = { + "add_line_numbers_to_hunks": True, + "disable_extra_lines": False, + "return_remaining_files": True, + } + if chunking_enabled: + diff_kwargs["return_prepared"] = True + output = get_pr_diff(self.git_provider, self.token_handler, model, **diff_kwargs) + if isinstance(output, PreparedPRDiff): + self.patches_diff = output.diff + self.remaining_files_list = output.remaining_files_list + elif isinstance(output, tuple): self.patches_diff, self.remaining_files_list = output else: self.patches_diff = output self.remaining_files_list = [] # a non-empty remaining_files_list means the token budget truncated the diff - if self.remaining_files_list and get_settings().pr_reviewer.get("enable_large_pr_chunking", False): - if await self._prepare_chunked_prediction(model): + if self.remaining_files_list and chunking_enabled: + prepared_diff = output if isinstance(output, PreparedPRDiff) else None + if await self._prepare_chunked_prediction(model, prepared_diff): return if self.patches_diff: @@ -785,18 +793,24 @@ async def _prepare_prediction(self, model: str) -> None: get_logger().warning(f"Empty diff for PR: {self.pr_url}") self.prediction = None - async def _prepare_chunked_prediction(self, model: str) -> bool: + async def _prepare_chunked_prediction(self, model: str, + prepared_diff: PreparedPRDiff | None = None) -> bool: """Review a too-large diff in chunks and merge the per-chunk verdicts. Returns False when chunking does not apply, leaving the single-call flow in place. """ + multi_diff_kwargs = { + "max_calls": get_settings().pr_reviewer.get("max_number_of_calls", 3), + "add_line_numbers": True, + "return_remaining_files": True, + } + if prepared_diff is not None: + multi_diff_kwargs["prepared_diff"] = prepared_diff patches_diff_list, remaining_files_list = get_pr_multi_diffs( self.git_provider, self.token_handler, model, - max_calls=get_settings().pr_reviewer.get("max_number_of_calls", 3), - add_line_numbers=True, - return_remaining_files=True) + **multi_diff_kwargs) if len(patches_diff_list) < 2: get_logger().info("Large-diff chunking produced a single chunk, reviewing the PR in one call") return False diff --git a/tests/unittest/test_pr_processing_core.py b/tests/unittest/test_pr_processing_core.py index 13ad4a9928..0c29243d19 100644 --- a/tests/unittest/test_pr_processing_core.py +++ b/tests/unittest/test_pr_processing_core.py @@ -13,22 +13,156 @@ class FakeTokenHandler: def __init__(self, prompt_tokens=100): self.prompt_tokens = prompt_tokens + self.count_calls = 0 def count_tokens(self, patch): + self.count_calls += 1 return len(patch.split()) class FakeProvider: def __init__(self, files): self.files = files + self.diff_calls = 0 + self.language_calls = 0 def get_diff_files(self): + self.diff_calls += 1 return self.files def get_languages(self): + self.language_calls += 1 return {"Python": 100} +def test_prepared_pr_diff_reuses_compressed_files_without_changing_chunks(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "large_patch_policy": settings.config.get("large_patch_policy", "skip"), + "verbosity_level": settings.config.verbosity_level, + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.large_patch_policy = "skip" + settings.config.verbosity_level = 0 + + hunk_sizes = (20, 40, 60, 80) + hunks = [ + "@@ -1 +1 @@\n-old\n+" + ("alpha " * size) + for size in hunk_sizes + ] + files = [ + FilePatchInfo("old\n", "new\n", hunks[index], f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ] + provider = FakeProvider(files) + token_handler = FakeTokenHandler(prompt_tokens=100) + + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1_700) + + try: + prepared = pr_processing.get_pr_diff( + provider, + token_handler, + "tiny-model", + add_line_numbers_to_hunks=True, + return_remaining_files=True, + return_prepared=True, + ) + + assert isinstance(prepared, pr_processing.PreparedPRDiff) + assert prepared.file_dict + expected_order = [f"file_{index}.py" for index in range(3, -1, -1)] + assert list(prepared.file_dict) == expected_order + calls_after_prepare = token_handler.count_calls + prepared_chunks = pr_processing.get_pr_multi_diffs( + provider, + token_handler, + "tiny-model", + max_calls=3, + add_line_numbers=True, + return_remaining_files=True, + prepared_diff=prepared, + ) + + fresh_provider = FakeProvider([ + FilePatchInfo("old\n", "new\n", hunks[index], f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ]) + fresh_chunks = pr_processing.get_pr_multi_diffs( + fresh_provider, + FakeTokenHandler(prompt_tokens=100), + "tiny-model", + max_calls=3, + add_line_numbers=True, + return_remaining_files=True, + ) + + assert prepared_chunks == fresh_chunks + prepared_diff_list, _ = prepared_chunks + combined_chunks = "\n".join(prepared_diff_list) + assert [combined_chunks.index(filename) for filename in expected_order] == sorted( + combined_chunks.index(filename) for filename in expected_order + ) + assert token_handler.count_calls == calls_after_prepare + assert (provider.diff_calls, provider.language_calls) == (1, 1) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + +@pytest.mark.parametrize( + ("prepared_model", "requested_model", "prepared_line_numbers", "requested_line_numbers"), + [ + ("tiny-model", "other-model", True, True), + ("tiny-model", "tiny-model", False, False), + ], +) +def test_prepared_pr_diff_is_not_reused_across_model_or_patch_format( + monkeypatch, prepared_model, requested_model, prepared_line_numbers, requested_line_numbers +): + settings = get_settings() + original_verbosity_level = settings.config.verbosity_level + settings.config.verbosity_level = 0 + hunk = "@@ -1 +1 @@\n-old\n+" + ("alpha " * 60) + files = [ + FilePatchInfo("old\n", "new\n", hunk, f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ] + provider = FakeProvider(files) + token_handler = FakeTokenHandler(prompt_tokens=100) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1_700) + + try: + prepared = pr_processing.get_pr_diff( + provider, + token_handler, + prepared_model, + add_line_numbers_to_hunks=prepared_line_numbers, + return_remaining_files=True, + return_prepared=True, + ) + assert isinstance(prepared, pr_processing.PreparedPRDiff) + + pr_processing.get_pr_multi_diffs( + provider, + token_handler, + requested_model, + max_calls=3, + add_line_numbers=requested_line_numbers, + return_remaining_files=True, + prepared_diff=prepared, + ) + + assert (provider.diff_calls, provider.language_calls) == (2, 2) + finally: + settings.config.verbosity_level = original_verbosity_level + + @pytest.mark.parametrize( "call_diff", [ diff --git a/tests/unittest/test_review_large_diff_chunking.py b/tests/unittest/test_review_large_diff_chunking.py index 0678d16412..a2b781dac0 100644 --- a/tests/unittest/test_review_large_diff_chunking.py +++ b/tests/unittest/test_review_large_diff_chunking.py @@ -10,6 +10,7 @@ import pytest +from pr_agent.algo.pr_processing import PreparedPRDiff from pr_agent.algo.review_finding_state import ParsedReviewState, reconcile_review_findings from pr_agent.config_loader import get_settings from pr_agent.tools.pr_reviewer import PRReviewer @@ -124,6 +125,29 @@ async def test_a_truncated_diff_is_reviewed_chunk_by_chunk_and_merged(chunking_e assert reviewer.remaining_files_list == ["still_left_out.py"] +@pytest.mark.asyncio +async def test_chunked_review_reuses_the_prepared_diff_for_the_same_model_attempt(chunking_enabled): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=[CHUNK_A, CHUNK_B]) + prepared = PreparedPRDiff( + diff="first compressed diff", + remaining_files_list=["b.py"], + file_dict={"a.py": {"patch": "chunk-a", "tokens": 10}}, + ) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=prepared) as get_pr_diff, + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b"], ["still_left_out.py"])) as get_pr_multi_diffs, + ): + await reviewer._prepare_prediction("model") + + assert get_pr_diff.call_args.kwargs["return_prepared"] is True + assert get_pr_multi_diffs.call_args.kwargs["prepared_diff"] is prepared + assert reviewer.review_chunk_count == 2 + assert reviewer.remaining_files_list == ["still_left_out.py"] + + @pytest.mark.asyncio async def test_max_number_of_calls_bounds_the_number_of_chunks(chunking_enabled): get_settings().set("pr_reviewer.max_number_of_calls", 7) From ed2817a8772d0740ed0556c5f58279f8fb2a355e Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Fri, 11 Sep 2026 17:53:56 +0800 Subject: [PATCH 067/238] perf(tickets): fetch Asana task context concurrently (#3293) Signed-off-by: hwan <3373484735@qq.com> --- pr_agent/tools/ticket_pr_compliance_check.py | 38 +++-- tests/unittest/test_ticket_compliance.py | 148 +++++++++++++++++++ 2 files changed, 174 insertions(+), 12 deletions(-) diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 34bbe7b2df..80e51cabe6 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -1,3 +1,4 @@ +import asyncio import copy import math import re @@ -226,26 +227,39 @@ async def _fetch_asana_ticket_contents( timeout = aiohttp.ClientTimeout(total=_get_asana_request_timeout()) headers = {"Authorization": f"Bearer {api_token.strip()}"} - tickets_content = [] async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session: # Bound attempts as well as successful results so invalid references cannot # multiply request latency, rate-limit usage, or warning logs. + selected_tickets = [] for ticket_url in ticket_urls[:max_tickets]: - if len(tickets_content) >= max_tickets: - break - task_gid = None try: task_gid = _get_asana_task_gid(ticket_url) - ticket_content = await _fetch_asana_ticket_content( - session, - ticket_url, - max_body_characters, - ) except Exception as e: - task_label = task_gid or "invalid reference" - get_logger().warning(f"Failed to fetch Asana task {task_label}: {e}") + get_logger().warning(f"Failed to fetch Asana task invalid reference: {e}") continue - tickets_content.append(ticket_content) + selected_tickets.append((ticket_url, task_gid)) + + async def fetch_ticket(ticket_url): + try: + return await _fetch_asana_ticket_content(session, ticket_url, max_body_characters), None + except Exception as e: + return None, e + + tasks = [asyncio.create_task(fetch_ticket(ticket_url)) for ticket_url, _task_gid in selected_tickets] + try: + results = await asyncio.gather(*tasks) + except BaseException: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + tickets_content = [] + for (_ticket_url, task_gid), (ticket_content, error) in zip(selected_tickets, results, strict=True): + if error is not None: + get_logger().warning(f"Failed to fetch Asana task {task_gid}: {error}") + continue + tickets_content.append(ticket_content) return tickets_content diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index 2fe8bbf1d8..d3a5e2a779 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -6,6 +6,8 @@ - Authenticated task fetching and provider integration - Edge cases (mixed content, no tickets, duplicates, API failures) """ +import asyncio + import pytest from pr_agent.config_loader import get_settings @@ -504,6 +506,152 @@ async def _failing_fetch(_session, ticket_url, _max_body_characters): assert tickets == [] assert attempted_urls == ticket_urls[:3] + @pytest.mark.asyncio + async def test_asana_ticket_fetches_run_concurrently_and_preserve_input_order(self, monkeypatch): + active_fetches = 0 + max_active_fetches = 0 + all_started = asyncio.Event() + + async def _coordinated_fetch(_session, ticket_url, _max_body_characters): + nonlocal active_fetches, max_active_fetches + active_fetches += 1 + max_active_fetches = max(max_active_fetches, active_fetches) + if active_fetches == 3: + all_started.set() + try: + await asyncio.wait_for(all_started.wait(), timeout=0.1) + task_gid = tpc._get_asana_task_gid(ticket_url) + return { + "ticket_id": task_gid, + "ticket_url": ticket_url, + "title": f"Asana task {task_gid}", + "body": "", + "labels": "", + } + finally: + active_fetches -= 1 + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _coordinated_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + tickets = await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert max_active_fetches == 3 + assert [ticket["ticket_url"] for ticket in tickets] == ticket_urls + + @pytest.mark.asyncio + async def test_asana_ticket_fetches_keep_successes_ordered_after_partial_failure(self, monkeypatch): + async def _partially_failing_fetch(_session, ticket_url, _max_body_characters): + task_gid = tpc._get_asana_task_gid(ticket_url) + await asyncio.sleep({"111111111111": 0.02, "222222222222": 0, "333333333333": 0.01}[task_gid]) + if task_gid == "222222222222": + raise RuntimeError("task unavailable") + return { + "ticket_id": task_gid, + "ticket_url": ticket_url, + "title": f"Asana task {task_gid}", + "body": "", + "labels": "", + } + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _partially_failing_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + tickets = await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert [ticket["ticket_id"] for ticket in tickets] == ["111111111111", "333333333333"] + + @pytest.mark.asyncio + async def test_asana_ticket_timeout_does_not_cancel_other_fetches(self, monkeypatch): + async def _timeout_one_fetch(_session, ticket_url, _max_body_characters): + task_gid = tpc._get_asana_task_gid(ticket_url) + if task_gid == "222222222222": + raise TimeoutError("task timed out") + return { + "ticket_id": task_gid, + "ticket_url": ticket_url, + "title": f"Asana task {task_gid}", + "body": "", + "labels": "", + } + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _timeout_one_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + tickets = await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert [ticket["ticket_id"] for ticket in tickets] == ["111111111111", "333333333333"] + + @pytest.mark.asyncio + async def test_asana_ticket_batch_cancellation_cleans_up_in_flight_fetches(self, monkeypatch): + active_fetches = 0 + all_started = asyncio.Event() + + async def _blocking_fetch(_session, _ticket_url, _max_body_characters): + nonlocal active_fetches + active_fetches += 1 + if active_fetches == 3: + all_started.set() + try: + await asyncio.Event().wait() + finally: + active_fetches -= 1 + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _blocking_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + batch = asyncio.create_task(tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000)) + await asyncio.wait_for(all_started.wait(), timeout=0.1) + + batch.cancel() + with pytest.raises(asyncio.CancelledError): + await batch + + assert active_fetches == 0 + + @pytest.mark.asyncio + async def test_asana_ticket_child_abort_cancels_and_drains_siblings(self, monkeypatch): + class FetchAborted(BaseException): + pass + + active_fetches = 0 + all_started = asyncio.Event() + + async def _aborting_fetch(_session, ticket_url, _max_body_characters): + nonlocal active_fetches + active_fetches += 1 + if active_fetches == 3: + all_started.set() + try: + await asyncio.wait_for(all_started.wait(), timeout=0.1) + if tpc._get_asana_task_gid(ticket_url) == "111111111111": + raise FetchAborted("child fetch aborted") + await asyncio.Event().wait() + finally: + active_fetches -= 1 + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _aborting_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + with pytest.raises(FetchAborted, match="child fetch aborted"): + await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert active_fetches == 0 + @pytest.mark.asyncio async def test_invalid_asana_url_does_not_abort_valid_batch_entry(self, monkeypatch): attempted_urls = [] From 97703efa6d38eaf0ddac8adb1b6e744578a88961 Mon Sep 17 00:00:00 2001 From: Chris <2529187+dellch@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:24:12 -0700 Subject: [PATCH 068/238] feat: add Jira ticket-context support (#2420) Co-authored-by: Claude Opus 4.8 Co-authored-by: Ismael Martinez Ramos --- .../core-abilities/fetching_ticket_context.md | 209 +------- .../usage-guide/configuration_reference.md | 7 + pr_agent/settings/.secrets_template.toml | 6 + pr_agent/settings/configuration.toml | 13 + pr_agent/tools/ticket_pr_compliance_check.py | 242 ++++++++- tests/unittest/test_config_reference.py | 2 +- tests/unittest/test_jira_ticket_extraction.py | 483 ++++++++++++++++++ tests/unittest/test_ticket_compliance.py | 35 ++ 8 files changed, 801 insertions(+), 196 deletions(-) create mode 100644 tests/unittest/test_jira_ticket_extraction.py diff --git a/docs/docs/core-abilities/fetching_ticket_context.md b/docs/docs/core-abilities/fetching_ticket_context.md index 9277b9b573..46a001d6a8 100644 --- a/docs/docs/core-abilities/fetching_ticket_context.md +++ b/docs/docs/core-abilities/fetching_ticket_context.md @@ -1,9 +1,10 @@ # Fetching Ticket Context for PRs -`Supported Git Platforms: GitHub, GitLab, Bitbucket` +`Supported Git Platforms: GitHub, GitLab, Bitbucket, Azure DevOps` -!!! note "Branch-name issue linking: GitHub only (for now)" - Extracting issue links from the **branch name** (and the optional `branch_issue_regex` setting) is currently implemented for **GitHub only**. Support for GitLab, Bitbucket, and other platforms is planned for a later release. The GitHub flow was the most relevant to implement first; other providers will follow. +!!! note "Branch-name linking: Jira keys on all providers; numeric GitHub issues on GitHub only" + **Jira** ticket keys (e.g. `ABC-123`) are extracted from the branch name on **every git provider**. + Extracting **numeric GitHub issue** links from the branch name (and the optional `branch_issue_regex` setting) is currently implemented for **GitHub only**; support for other providers is planned for a later release. ## Overview @@ -147,7 +148,11 @@ the established ticket-extraction behavior of existing providers. ## Jira Integration -We support both Jira Cloud and Jira Server/Data Center. +Only **Jira Cloud** is supported. The base URL is derived from a validated site name +(`jira_site` → `https://.atlassian.net`) rather than taken as a free-form URL, so +the configured destination is always an Atlassian Cloud host. Jira Server / Data Center +(self-hosted) uses a free-form host and is not supported yet; it can be added once +base-URL handling for the self-hosted case is settled. ### Jira Cloud @@ -169,205 +174,37 @@ You can create an API token from your Atlassian account: ```toml [jira] -jira_api_token = "YOUR_API_TOKEN" +jira_site = "" # the "" in https://.atlassian.net (e.g. "mycompany") jira_api_email = "YOUR_EMAIL" +jira_api_token = "YOUR_API_TOKEN" ``` -### Jira Data Center/Server - -#### Using Basic Authentication for Jira Data Center/Server - -You can use your Jira username and password to authenticate with Jira Data Center/Server. - -In your Configuration file/Environment variables/Secrets file, add the following lines: - -```toml -jira_api_email = "your_username" -jira_api_token = "your_password" -``` - -(Note that indeed the 'jira_api_email' field is used for the username, and the 'jira_api_token' field is used for the user password.) - -##### Validating Basic authentication via Python script - -If you are facing issues retrieving tickets in PR-Agent with Basic auth, you can validate the flow using a Python script. -This following steps will help you check if the basic auth is working correctly, and if you can access the Jira ticket details: - -1. run `pip install jira==3.8.0` - -2. run the following Python script (after replacing the placeholders with your actual values): - -???- example "Script to validate basic auth" - - ```python - from jira import JIRA - +`jira_site` is your Jira Cloud site name — the part before `.atlassian.net` (for +`https://mycompany.atlassian.net`, the site is `mycompany`). PR-Agent builds the base URL +as `https://.atlassian.net`; it does not accept a full URL, so configuration +cannot redirect the authenticated request to another host. Store `jira_api_email` and +`jira_api_token` as secrets (environment variables or the secrets file), not in +repository-committed configuration. - if __name__ == "__main__": - try: - # Jira server URL - server = "https://..." - # Basic auth - username = "..." - password = "..." - # Jira ticket code (e.g. "PROJ-123") - ticket_id = "..." +#### Acceptance criteria / requirements (optional) - print("Initializing JiraServerTicketProvider with JIRA server") - # Initialize JIRA client - jira = JIRA( - server=server, - basic_auth=(username, password), - timeout=30 - ) - if jira: - print(f"JIRA client initialized successfully") - else: - print("Error initializing JIRA client") - - # Fetch ticket details - ticket = jira.issue(ticket_id) - print(f"Ticket title: {ticket.fields.summary}") - - except Exception as e: - print(f"Error fetching JIRA ticket details: {e}") - ``` - -#### Using a Personal Access Token (PAT) for Jira Data Center/Server - -1. Create a [Personal Access Token (PAT)](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) in your Jira account -2. In your Configuration file/Environment variables/Secrets file, add the following lines: +To include a ticket's acceptance criteria in the analysis, set `jira_requirements_field` +to the id of the custom field that holds it. The field id is specific to your Jira +instance (for example `customfield_10127`); leave it empty to skip requirements. ```toml [jira] -jira_base_url = "YOUR_JIRA_BASE_URL" # e.g. https://jira.example.com -jira_api_token = "YOUR_API_TOKEN" +jira_requirements_field = "customfield_10127" ``` -##### Validating PAT token via Python script - -If you are facing issues retrieving tickets in PR-Agent with PAT token, you can validate the flow using a Python script. -This following steps will help you check if the token is working correctly, and if you can access the Jira ticket details: - -1. run `pip install jira==3.8.0` - -2. run the following Python script (after replacing the placeholders with your actual values): - -??? example- "Script to validate PAT token" - - ```python - from jira import JIRA - - - if __name__ == "__main__": - try: - # Jira server URL - server = "https://..." - # Jira PAT token - token_auth = "..." - # Jira ticket code (e.g. "PROJ-123") - ticket_id = "..." - - print("Initializing JiraServerTicketProvider with JIRA server") - # Initialize JIRA client - jira = JIRA( - server=server, - token_auth=token_auth, - timeout=30 - ) - if jira: - print(f"JIRA client initialized successfully") - else: - print("Error initializing JIRA client") - - # Fetch ticket details - ticket = jira.issue(ticket_id) - print(f"Ticket title: {ticket.fields.summary}") - - except Exception as e: - print(f"Error fetching JIRA ticket details: {e}") - ``` - - -### Multi-JIRA Server Configuration - -PR-Agent supports connecting to multiple JIRA servers using different authentication methods. - -=== "Email/Token (Basic Auth)" - - Configure multiple servers using Email/Token authentication: - - - `jira_servers`: List of JIRA server URLs - - `jira_api_token`: List of API tokens (for Cloud) or passwords (for Data Center) - - `jira_api_email`: List of emails (for Cloud) or usernames (for Data Center) - - `jira_base_url`: Default server for ticket IDs like `PROJ-123`, Each repository can configure (local config file) its own `jira_base_url` to choose which server to use by default. - - **Example Configuration:** - ```toml - [jira] - # Server URLs - jira_servers = ["https://company.atlassian.net", "https://datacenter.jira.com"] - - # API tokens/passwords - jira_api_token = ["cloud_api_token_here", "datacenter_password"] - - # Emails/usernames (both required) - jira_api_email = ["user@company.com", "datacenter_username"] - - # Default server for ticket IDs - jira_base_url = "https://company.atlassian.net" - ``` - -=== "PAT Auth" - - Configure multiple servers using Personal Access Token authentication: - - - `jira_servers`: List of JIRA server URLs - - `jira_api_token`: List of PAT tokens - - `jira_api_email`: Not needed (can be omitted or left empty) - - `jira_base_url`: Default server for ticket IDs like `PROJ-123`, Each repository can configure (local config file) its own `jira_base_url` to choose which server to use by default. - - **Example Configuration:** - ```toml - [jira] - # Server URLs - jira_servers = ["https://server1.jira.com", "https://server2.jira.com"] - - # PAT tokens only - jira_api_token = ["pat_token_1", "pat_token_2"] - - # Default server for ticket IDs - jira_base_url = "https://server1.jira.com" - ``` - - **Mixed Authentication (Email/Token + PAT):** - ```toml - [jira] - jira_servers = ["https://company.atlassian.net", "https://server.jira.com"] - jira_api_token = ["cloud_api_token", "server_pat_token"] - jira_api_email = ["user@company.com", ""] # Empty for PAT - ``` - - - - ### How to link a PR to a Jira ticket To integrate with Jira, you can link your PR to a ticket using either of these methods: **Method 1: Description Reference:** -Include a ticket reference in your PR description, using either the complete URL format `https://.atlassian.net/browse/ISSUE-123` or the shortened ticket ID `ISSUE-123` (without prefix or suffix for the shortened ID). +Include a ticket reference in your PR description, using either the complete URL format `https://.atlassian.net/browse/ISSUE-123` or the shortened ticket ID `ISSUE-123` (without prefix or suffix for the shortened ID). **Method 2: Branch Name Detection:** Name your branch with the ticket ID as a prefix (e.g., `ISSUE-123-feature-description` or `ISSUE-123/feature-description`). - -!!! note "Jira Base URL" - For shortened ticket IDs or branch detection (method 2 for JIRA cloud), you must configure the Jira base URL in your configuration file under the [jira] section: - - ```toml - [jira] - jira_base_url = "https://.atlassian.net" - ``` - Where `` is your Jira organization identifier (e.g., `mycompany` for `https://mycompany.atlassian.net`). diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 7aacc4f7c6..a456b52b25 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -454,6 +454,13 @@ _This section only documents commented-out examples; see the [TOML source](https | `url` | "" | URL to the BitBucket Server instance | +## `[jira]` + +| Key | Default | Description | +| --- | --- | --- | +| `jira_requirements_field` | "" | Custom field id holding acceptance criteria / requirements, mapped to the ticket "requirements" section. Instance-specific (e.g. "customfield_10127"); empty disables it. | + + ## `[litellm]` | Key | Default | Description | diff --git a/pr_agent/settings/.secrets_template.toml b/pr_agent/settings/.secrets_template.toml index 14585ee955..02e03c697c 100644 --- a/pr_agent/settings/.secrets_template.toml +++ b/pr_agent/settings/.secrets_template.toml @@ -124,6 +124,12 @@ api_token = "" org = "" pat = "" +[jira] +# For fetching ticket context from Jira Cloud (only Jira Cloud is supported) +jira_site = "" # the "" in https://.atlassian.net (e.g. "your-org") +jira_api_email = "" # Atlassian account email +jira_api_token = "" # Atlassian API token + [azure_devops_server] # For Azure devops Server basic auth - configured in the webhook creation # Optional, uncomment if you want to use Azure devops webhooks. Value assigned when you create the webhook diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index a15a1a1f6a..79bc5edccf 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -411,6 +411,19 @@ webhook_password = "" # url = "https://git.bitbucket.com" url = "" # URL to the BitBucket Server instance +[jira] +# Jira Cloud ticket context. Only Jira Cloud is supported; the base URL is built as +# https://.atlassian.net from the site name below, so repo configuration +# cannot redirect the authenticated request to another host. +# Credentials: fill and place in .secrets.toml, or set via environment variables +# (e.g. jira__jira_api_token). +# jira_site = "your-org" # the "" in https://.atlassian.net +# jira_api_email = "..." # Atlassian account email +# jira_api_token = "..." # Atlassian API token +# Custom field id holding acceptance criteria / requirements, mapped to the ticket +# "requirements" section. Instance-specific (e.g. "customfield_10127"); empty disables it. +jira_requirements_field = "" + [litellm] # use_client = false # drop_params = false diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 80e51cabe6..0b23e8c659 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -6,6 +6,7 @@ from urllib.parse import urlparse import aiohttp +from atlassian import Jira from pr_agent.algo.pr_processing import OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD from pr_agent.algo.token_handler import TokenHandler @@ -27,17 +28,66 @@ # number written in a branch name and in the description should resolve the same way. MAX_SHORTHAND_ISSUE_DIGITS = 6 +# Cap on the total tickets analysed per PR, enforced at the Jira step (see add_jira_tickets). +# The provider-native lookups keep their own budgets (MAX_GITHUB_TICKETS, MAX_GITLAB_TICKETS, +# MAX_ASANA_TICKETS). +MAX_TICKETS = 5 +# Upper bound on Jira lookups per PR. The key pattern also matches strings like "SHA-256" +# and "UTF-8", so candidates are tried until MAX_TICKETS resolve rather than truncated up +# front; this bounds the cost of a PR whose text is mostly key-shaped noise. +MAX_JIRA_FETCH_ATTEMPTS = 10 +# Max characters kept from any ticket body or requirements field. +MAX_TICKET_CHARACTERS = 10000 + +# Jira REST API version. Pinned to "2" because extract_jira_tickets() reads description +# and custom fields as plain strings. v2 returns them that way; v3 returns ADF JSON dicts +# that would need separate parsing. The atlassian-python-api client defaults to "2" today, +# but pinning keeps the contract stable across dependency upgrades. +JIRA_API_VERSION = "2" + +# Jira Cloud site name (the "" in https://.atlassian.net). Only Jira Cloud is +# supported: the base URL is built from this name rather than taken as a free-form URL, so +# repo-controlled config cannot redirect the authenticated request (and the token) to an +# arbitrary host. The name must be a single DNS label (letters, digits, hyphens) so it +# cannot contain '.', '/', ':', '@' etc. that would let it escape *.atlassian.net. +JIRA_SITE_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE) + + +def _jira_cloud_base_url(): + """ + Return the Jira Cloud base URL built from the configured site name, or None if the + site is missing or invalid. Building the URL from a validated site name (rather than + accepting a free-form base URL from settings) ensures the authenticated request can + only ever go to https://.atlassian.net, even if settings were overridden by + untrusted repo configuration. + """ + site = get_settings().get("JIRA.JIRA_SITE", None) + if not site: + return None + site = str(site).strip() + if not JIRA_SITE_PATTERN.match(site): + get_logger().warning( + f"Invalid jira_site '{site}'; expected a Jira Cloud site name like 'mycompany' " + f"(the '' in https://.atlassian.net). Skipping Jira ticket lookup.") + return None + return f"https://{site}.atlassian.net" + def find_jira_tickets(text): - # Regular expression patterns for JIRA tickets + # Regular expression patterns for JIRA tickets. Matching is case-insensitive so + # lowercased branch names (e.g. bugfix/abc-123-description) are detected; keys are + # normalized to upper case to match Jira's canonical form. patterns = [ r'\b[A-Z]{2,10}-\d{1,7}\b', # Standard JIRA ticket format (e.g., PROJ-123) r'(?:https?://[^\s/]+/browse/)?([A-Z]{2,10}-\d{1,7})\b' # JIRA URL or just the ticket ] - tickets = set() + # Preserve first-seen order while de-duplicating, so the MAX_TICKETS cap applied + # later is deterministic across runs (a plain set would fetch arbitrary tickets). + seen = set() + tickets = [] for pattern in patterns: - matches = re.findall(pattern, text) + matches = re.findall(pattern, text, flags=re.IGNORECASE) for match in matches: if isinstance(match, tuple): # If it's a tuple (from the URL pattern), take the last non-empty group @@ -45,9 +95,164 @@ def find_jira_tickets(text): else: ticket = match if ticket: - tickets.add(ticket) + ticket = ticket.upper() + if ticket not in seen: + seen.add(ticket) + tickets.append(ticket) - return list(tickets) + return tickets + + +def _get_jira_client(): + """ + Build a Jira Cloud client from the [jira] settings. Returns None if Jira is not + configured. Only Jira Cloud is supported: the base URL is derived from a validated + site name (jira_site) so untrusted repo configuration cannot redirect the + authenticated request to an arbitrary host. Cloud authenticates with the account + email + API token. The REST API version is pinned via JIRA_API_VERSION (see its + definition for why). + """ + site = get_settings().get("JIRA.JIRA_SITE", None) + api_email = get_settings().get("JIRA.JIRA_API_EMAIL", None) + api_token = get_settings().get("JIRA.JIRA_API_TOKEN", None) + base_url = _jira_cloud_base_url() # None if site is missing or invalid (already warned if invalid) + if not (base_url and api_email and api_token): + # Warn when Jira is partially configured: some [jira] value is set but the required + # site + email + token are incomplete, which is likely a mistake. Stay silent when + # nothing is set (Jira simply not in use), and don't double-warn when the site was + # set but invalid (_jira_cloud_base_url already warned about that). + site_set_but_invalid = site and not base_url + if any([site, api_email, api_token]) and not site_set_but_invalid: + missing = [ + name for name, value in ( + ("jira_site", site), + ("jira_api_email", api_email), + ("jira_api_token", api_token), + ) if not value + ] + get_logger().warning( + f"Jira is partially configured; skipping Jira ticket lookup. Missing: {', '.join(missing)}") + return None + try: + return Jira(url=base_url, username=api_email, password=api_token, api_version=JIRA_API_VERSION) + except Exception as e: + get_logger().error(f"Failed to initialize Jira client: {e}", + artifact={"traceback": traceback.format_exc()}) + return None + + +def extract_jira_tickets(text, max_characters=MAX_TICKET_CHARACTERS, max_tickets=MAX_TICKETS): + """ + Find Jira ticket keys in the given text and fetch their content. Returns a list of + ticket dicts in the same shape used by the rest of the ticket-analysis flow. Returns + an empty list when no keys are found or when Jira is not configured. + + Candidates are tried in first-seen order until max_tickets resolve or + MAX_JIRA_FETCH_ATTEMPTS lookups have been made. Counting resolved tickets rather than + truncating the candidate list keeps key-shaped noise ("SHA-256", "UTF-8") from + displacing a real ticket that appears later in the text. + """ + # Look for keys before building a client: most PRs have none, and building the + # client first would do needless work (and log a noisy init failure if Jira is + # misconfigured) even when there is nothing to fetch. + keys = find_jira_tickets(text or "") + if not keys: + return [] + + jira_client = _get_jira_client() + if jira_client is None: + return [] + + base_url = _jira_cloud_base_url() or "" + # Custom field that holds acceptance criteria / requirements. The field id is + # instance-specific (e.g. "customfield_10127"), so it must be configured; empty + # means no requirements are extracted. + requirements_field = get_settings().get("JIRA.JIRA_REQUIREMENTS_FIELD", "") or "" + tickets_content = [] + for attempt, key in enumerate(keys, start=1): + if len(tickets_content) >= max_tickets: + get_logger().info(f"Reached the cap of {max_tickets} Jira tickets; skipping remaining keys") + break + if attempt > MAX_JIRA_FETCH_ATTEMPTS: + get_logger().info( + f"Stopped after {MAX_JIRA_FETCH_ATTEMPTS} Jira lookups; skipping remaining keys") + break + try: + issue = jira_client.issue(key) + except Exception as e: + get_logger().warning(f"Failed to fetch Jira ticket {key}: {e}") + continue + if not issue: + continue + + fields = issue.get("fields", {}) or {} + # The client is pinned to REST v2 (see _get_jira_client), which returns + # description and rich-text custom fields as plain wiki-markup strings. The + # isinstance guards below defend against anything non-string (e.g. v3 ADF dicts). + body = fields.get("description") or "" + if not isinstance(body, str): + body = "" + if len(body) > max_characters: + body = body[:max_characters] + "..." + + requirements = "" + if requirements_field: + requirements = fields.get(requirements_field) or "" + if not isinstance(requirements, str): + requirements = "" + if len(requirements) > max_characters: + requirements = requirements[:max_characters] + "..." + + labels = fields.get("labels", []) or [] + tickets_content.append({ + "ticket_id": key, + "ticket_url": f"{base_url}/browse/{key}" if base_url else "", + "title": fields.get("summary", ""), + "body": body, + "requirements": requirements, + "labels": ", ".join(labels), + }) + return tickets_content + + +def _get_pr_title(git_provider) -> str: + """Return the PR/MR title across providers (GitHub/Bitbucket use .pr, GitLab .mr).""" + for attr in ("pr", "mr"): + obj = getattr(git_provider, attr, None) + title = getattr(obj, "title", None) + if title: + return title + return "" + + +def add_jira_tickets(git_provider, tickets_content): + """ + Provider-agnostic Jira lookup. Scans the PR title, description and branch name for + Jira ticket keys and appends any found tickets to tickets_content (de-duplicated by + ticket_url). No-op when Jira is not configured. Works for any git provider, since it + only relies on get_user_description() and get_pr_branch(). + + MAX_TICKETS is the overall per-PR cap, so any provider-native tickets already in + tickets_content count against it: only the remaining budget is offered to the Jira + lookup, keeping the existing tickets first. + """ + try: + if len(tickets_content) >= MAX_TICKETS: + return tickets_content + jira_context = "\n".join(filter(None, [ + _get_pr_title(git_provider), + git_provider.get_user_description() or "", + git_provider.get_pr_branch() or "", + ])) + existing_urls = {t.get("ticket_url") for t in tickets_content} + remaining = MAX_TICKETS - len(tickets_content) + for jira_ticket in extract_jira_tickets(jira_context, MAX_TICKET_CHARACTERS, remaining): + if jira_ticket.get("ticket_url") not in existing_urls: + tickets_content.append(jira_ticket) + except Exception as e: + get_logger().error(f"Error extracting Jira tickets: {e}", + artifact={"traceback": traceback.format_exc()}) + return tickets_content _ASANA_TASK_URL_PATTERN = re.compile( @@ -493,7 +698,6 @@ def _provider_supports(git_provider, capability: str) -> bool: async def extract_tickets(git_provider): - MAX_TICKET_CHARACTERS = 10000 try: user_description = _get_user_description_for_asana(git_provider) asana_ticket_urls = find_asana_tickets(user_description) @@ -602,6 +806,8 @@ async def extract_tickets(git_provider): }) tickets_content.extend(asana_tickets_content) + # Provider-agnostic Jira lookup (see add_jira_tickets); no-op when Jira is unconfigured. + add_jira_tickets(git_provider, tickets_content) return tickets_content elif _provider_supports(git_provider, "supports_issue_reference_tickets"): @@ -637,6 +843,8 @@ async def extract_tickets(git_provider): ) tickets_content.extend(asana_tickets_content) + # Provider-agnostic Jira lookup (see add_jira_tickets); no-op when Jira is unconfigured. + add_jira_tickets(git_provider, tickets_content) return tickets_content elif _provider_supports(git_provider, "supports_linked_work_item_tickets"): @@ -648,13 +856,21 @@ async def extract_tickets(git_provider): if len(ticket_body_str) > MAX_TICKET_CHARACTERS: ticket_body_str = ticket_body_str[:MAX_TICKET_CHARACTERS] + "..." + # Cap acceptance criteria like the body, so a large work-item field + # can't push an unbounded blob into the review prompt. + requirements_str = ticket.get("acceptance_criteria", "") or "" + if not isinstance(requirements_str, str): + requirements_str = "" + if len(requirements_str) > MAX_TICKET_CHARACTERS: + requirements_str = requirements_str[:MAX_TICKET_CHARACTERS] + "..." + tickets_content.append( { "ticket_id": ticket.get("id"), "ticket_url": ticket.get("url"), "title": ticket.get("title"), "body": ticket_body_str, - "requirements": ticket.get("acceptance_criteria", ""), + "requirements": requirements_str, "labels": ", ".join(ticket.get("labels", [])), } ) @@ -666,10 +882,18 @@ async def extract_tickets(git_provider): # Preserve the existing Azure work-item result set. The independently bounded # Asana results add context without imposing a new cap on Azure's established behaviour. tickets_content.extend(asana_tickets_content) + # Provider-agnostic Jira lookup (see add_jira_tickets); no-op when Jira is unconfigured. + add_jira_tickets(git_provider, tickets_content) return tickets_content - if asana_ticket_urls: - return asana_tickets_content + # Providers with no ticket integration of their own still reach Jira: keys are + # usually referenced in the PR title, description or branch name rather than by a + # provider-native link. The Asana results seed the list so they count against + # MAX_TICKETS, as on the provider-specific paths. Returning None when nothing at + # all was found keeps the "provider unsupported" contract intact. + tickets_content = add_jira_tickets(git_provider, list(asana_tickets_content)) + if asana_ticket_urls or tickets_content: + return tickets_content except Exception as e: get_logger().error(f"Error extracting tickets error= {e}", diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index f4c0102832..d58114b717 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 297 + assert sum(keys.values()) == 298 assert { "model", diff --git a/tests/unittest/test_jira_ticket_extraction.py b/tests/unittest/test_jira_ticket_extraction.py new file mode 100644 index 0000000000..b5272488b1 --- /dev/null +++ b/tests/unittest/test_jira_ticket_extraction.py @@ -0,0 +1,483 @@ +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers import AzureDevopsProvider +from pr_agent.tools.ticket_pr_compliance_check import ( + MAX_JIRA_FETCH_ATTEMPTS, + MAX_TICKET_CHARACTERS, + MAX_TICKETS, + _get_jira_client, + _get_pr_title, + _jira_cloud_base_url, + add_jira_tickets, + extract_jira_tickets, + extract_tickets, + find_jira_tickets, +) + +# Keys the tests mutate via get_settings().set(...). Snapshot and restore them around +# every test so values (e.g. JIRA_REQUIREMENTS_FIELD) don't leak between tests. +_JIRA_KEYS = ( + "JIRA.JIRA_SITE", + "JIRA.JIRA_API_EMAIL", + "JIRA.JIRA_API_TOKEN", + "JIRA.JIRA_REQUIREMENTS_FIELD", +) + + +@pytest.fixture(autouse=True) +def restore_jira_settings(): + saved = {key: get_settings().get(key, None) for key in _JIRA_KEYS} + yield + for key, value in saved.items(): + get_settings().set(key, value) + + +class TestFindJiraTickets: + """Jira key extraction from arbitrary text (PR title, description, branch name).""" + + def test_uppercase_key_with_prefix(self): + """feature/ABC-123-description -> ABC-123""" + assert find_jira_tickets("feature/ABC-123-description-of-branch") == ["ABC-123"] + + def test_lowercase_key_with_prefix_normalized(self): + """bugfix/abc-123-description -> ABC-123 (case-insensitive, normalized to upper)""" + assert find_jira_tickets("bugfix/abc-123-description-of-branch") == ["ABC-123"] + + def test_mixed_case_key_normalized(self): + """Abc-123 -> ABC-123""" + assert find_jira_tickets("Abc-123-fix") == ["ABC-123"] + + def test_arbitrary_prefix_segment(self): + """Any prefix segment, not just feature/bugfix.""" + assert find_jira_tickets("chore/PROJ-45-cleanup") == ["PROJ-45"] + assert find_jira_tickets("hotfix/proj-45-cleanup") == ["PROJ-45"] + + def test_key_at_start_no_prefix(self): + """ABC-123-fix -> ABC-123""" + assert find_jira_tickets("ABC-123-fix") == ["ABC-123"] + + def test_key_anywhere_in_branch(self): + """Key embedded in the middle of a branch name is still found.""" + assert find_jira_tickets("release/v1.2.3-ABC-9-final") == ["ABC-9"] + + def test_key_in_description_text(self): + """Key mentioned in free text.""" + assert find_jira_tickets("This implements ABC-123 as discussed") == ["ABC-123"] + + def test_browse_url(self): + """Full Jira browse URL -> key.""" + assert find_jira_tickets( + "see https://acme.atlassian.net/browse/ABC-123 for details" + ) == ["ABC-123"] + + def test_no_ticket(self): + """Branch with no key -> []""" + assert find_jira_tickets("feature/no-ticket-here") == [] + assert find_jira_tickets("") == [] + + def test_multiple_tickets_deduped_in_order(self): + """Distinct keys are returned de-duplicated and case-normalized, in first-seen + order. Order must be stable so the later MAX_TICKETS cap is deterministic.""" + result = find_jira_tickets("ABC-1 and DEF-2, again ABC-1 and abc-1") + assert result == ["ABC-1", "DEF-2"] + + def test_order_preserved_across_patterns(self): + """First-seen order holds even when keys arrive via different patterns (plain + key vs. browse URL).""" + result = find_jira_tickets( + "GHI-3 first, then https://acme.atlassian.net/browse/ABC-1, then DEF-2" + ) + assert result == ["GHI-3", "ABC-1", "DEF-2"] + + +class TestExtractJiraTickets: + """End-to-end extraction: find keys, fetch via the Jira client, map to ticket dicts.""" + + def _configure_jira(self): + get_settings().set("JIRA.JIRA_SITE", "acme") + get_settings().set("JIRA.JIRA_API_EMAIL", "me@acme.com") + get_settings().set("JIRA.JIRA_API_TOKEN", "token123") + + def _disable_jira(self): + get_settings().set("JIRA.JIRA_SITE", "") + get_settings().set("JIRA.JIRA_API_EMAIL", "") + get_settings().set("JIRA.JIRA_API_TOKEN", "") + + def _fake_client(self, fields=None): + fields = fields or {"summary": "Title", "description": "Body", "labels": ["a"]} + client = MagicMock() + client.issue.return_value = {"fields": fields} + return client + + def test_returns_empty_when_not_configured(self): + self._disable_jira() + assert extract_jira_tickets("bugfix/abc-123-x") == [] + + def test_no_client_built_when_no_keys(self): + """No Jira keys in the text -> return early without constructing a client, so a + keyless PR pays no client-init cost (or noisy init-failure log).""" + self._configure_jira() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls: + result = extract_jira_tickets("nothing ticket-like here") + assert result == [] + jira_cls.assert_not_called() + + def test_fetches_lowercase_branch_key(self): + """The whole point: a lowercased branch key is detected and fetched as upper.""" + self._configure_jira() + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("bugfix/abc-123-description-of-branch") + client.issue.assert_called_once_with("ABC-123") + assert len(result) == 1 + assert result[0]["ticket_id"] == "ABC-123" + assert result[0]["ticket_url"] == "https://acme.atlassian.net/browse/ABC-123" + assert result[0]["title"] == "Title" + assert result[0]["labels"] == "a" + + def test_requirements_field_populated_when_configured(self): + """When jira_requirements_field is set, that custom field maps to requirements.""" + self._configure_jira() + get_settings().set("JIRA.JIRA_REQUIREMENTS_FIELD", "customfield_10127") + client = MagicMock() + client.issue.return_value = {"fields": { + "summary": "T", "description": "B", "labels": [], + "customfield_10127": "Acceptance criteria text", + }} + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("ABC-1") + assert result[0]["requirements"] == "Acceptance criteria text" + + def test_requirements_truncated_like_body(self): + """A large requirements custom field is capped the same way the body is, so it + can't push an unbounded blob into the review prompt.""" + self._configure_jira() + get_settings().set("JIRA.JIRA_REQUIREMENTS_FIELD", "customfield_10127") + client = MagicMock() + client.issue.return_value = {"fields": { + "summary": "T", "description": "B", "labels": [], + "customfield_10127": "x" * 50, + }} + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("ABC-1", max_characters=10) + assert result[0]["requirements"] == "x" * 10 + "..." + + def test_requirements_empty_when_field_not_configured(self): + """With no requirements field configured, requirements stays empty.""" + self._configure_jira() + get_settings().set("JIRA.JIRA_REQUIREMENTS_FIELD", "") + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("ABC-1") + assert result[0]["requirements"] == "" + + def test_checks_all_tickets_when_multiple(self): + """When several distinct keys are present, each is fetched.""" + self._configure_jira() + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("ABC-1 DEF-2 GHI-3") + fetched = {c.args[0] for c in client.issue.call_args_list} + assert fetched == {"ABC-1", "DEF-2", "GHI-3"} + assert len(result) == 3 + + def test_caps_resolved_tickets_not_candidates(self): + """The cap counts tickets that resolve, so it stops the fetch loop rather than + truncating the candidate list.""" + self._configure_jira() + client = self._fake_client() + keys = " ".join(f"ABC-{i}" for i in range(1, MAX_TICKETS + 3)) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets(keys) + assert client.issue.call_count == MAX_TICKETS + assert len(result) == MAX_TICKETS + + def test_noise_keys_do_not_displace_a_real_ticket(self): + """SHA-1, SHA-256, UTF-8 and AES-256 all match the key pattern. Capping candidates + before the fetch spends the budget on them and drops the ticket the PR names.""" + self._configure_jira() + + def fake_issue(key): + if key == "PROJ-4242": + return {"fields": {"summary": "Real", "description": "Body", "labels": []}} + raise Exception("404 not found") + + client = MagicMock() + client.issue.side_effect = fake_issue + text = ("Switch the digest from SHA-1 to SHA-256, normalise to UTF-8 and rotate " + "the AES-256 key. Implements PROJ-4242") + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets(text) + assert [t["ticket_id"] for t in result] == ["PROJ-4242"] + + def test_real_ticket_reached_past_more_noise_than_the_cap(self): + """The same property with more noise keys than MAX_TICKETS, so it holds whatever + the cap is set to.""" + self._configure_jira() + + def fake_issue(key): + if key == "PROJ-4242": + return {"fields": {"summary": "Real", "description": "Body", "labels": []}} + raise Exception("404 not found") + + client = MagicMock() + client.issue.side_effect = fake_issue + noise = " ".join(f"AES-{i}" for i in range(1, MAX_TICKETS + 3)) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets(f"{noise} PROJ-4242") + assert [t["ticket_id"] for t in result] == ["PROJ-4242"] + + def test_fetch_attempts_are_bounded(self): + """Key-shaped noise cannot make one PR pay unbounded authenticated Jira calls.""" + self._configure_jira() + client = MagicMock() + client.issue.side_effect = Exception("404 not found") + keys = " ".join(f"ABC-{i}" for i in range(1, MAX_JIRA_FETCH_ATTEMPTS + 6)) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets(keys) + assert client.issue.call_count == MAX_JIRA_FETCH_ATTEMPTS + assert result == [] + + def test_skips_ticket_on_fetch_error(self): + """A failed fetch for one key does not abort the others.""" + self._configure_jira() + client = MagicMock() + client.issue.side_effect = [ + Exception("404 not found"), + {"fields": {"summary": "Second", "description": "B", "labels": []}}, + ] + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("ABC-1 ABC-2") + assert len(result) == 1 + assert result[0]["title"] == "Second" + + def test_nonexistent_keys_are_skipped(self): + """Key-like noise (utf-8, sha-1) that does not resolve in Jira is skipped, + leaving only the real ticket.""" + self._configure_jira() + + def fake_issue(key): + if key == "ABC-123": + return {"fields": {"summary": "Real", "description": "Body", "labels": []}} + raise Exception("404 not found") + + client = MagicMock() + client.issue.side_effect = fake_issue + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("abc-123 utf-8") + assert [t["ticket_id"] for t in result] == ["ABC-123"] + + +class TestGetJiraClient: + """Cloud client construction: site-name -> base URL, email+token auth, v2 pin.""" + + def _set(self, site=None, email=None, token=None): + get_settings().set("JIRA.JIRA_SITE", site if site is not None else "") + get_settings().set("JIRA.JIRA_API_EMAIL", email if email is not None else "") + get_settings().set("JIRA.JIRA_API_TOKEN", token if token is not None else "") + + def test_cloud_builds_url_from_site_and_pins_v2(self): + """site name -> https://.atlassian.net, email/token basic auth, v2 pinned.""" + self._set(site="acme", email="me@acme.com", token="token123") + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls: + _get_jira_client() + jira_cls.assert_called_once_with( + url="https://acme.atlassian.net", username="me@acme.com", + password="token123", api_version="2", + ) + + def test_returns_none_when_not_configured(self): + self._set() + assert _get_jira_client() is None + + def test_returns_none_when_email_missing(self): + """Cloud requires email; site + token alone is incomplete.""" + self._set(site="acme", token="token123") + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls: + assert _get_jira_client() is None + jira_cls.assert_not_called() + + def test_no_warning_when_nothing_configured(self): + """Jira simply not in use -> return None silently, no misconfiguration warning.""" + self._set() + with patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_log: + assert _get_jira_client() is None + get_log.return_value.warning.assert_not_called() + + def test_warns_when_partially_configured(self): + """Some [jira] value set but the required site + email + token are incomplete + -> warn (likely a misconfiguration) and return None.""" + self._set(email="me@acme.com", token="token123") # site missing + with patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_log: + assert _get_jira_client() is None + get_log.return_value.warning.assert_called_once() + msg = get_log.return_value.warning.call_args.args[0] + assert "jira_site" in msg + assert "jira_api_token" not in msg # the one that IS set is not listed as missing + + +class TestJiraSiteInjection: + """jira_site is validated so repo-controlled config can't redirect the authenticated + request (and token) off *.atlassian.net. Building the URL from the site name means a + malicious value cannot express a different host.""" + + # Values a malicious repo config might inject to try to escape *.atlassian.net. + INJECTION_ATTEMPTS = [ + "evil.website.com?", # query separator + "evil.com#", # fragment + "evil.com/", # path separator + "evil.com", # dotted host + "evil.com:443", # port + "x@evil.com", # userinfo + "a.evil.com", # subdomain prefix + "../../evil", # path traversal + "evil%2ecom", # encoded dot + "evil_underscore", # underscore not allowed in DNS label + "evil .com", # internal whitespace + "", # empty + ] + + @pytest.mark.parametrize("bad_site", INJECTION_ATTEMPTS) + def test_injection_attempt_rejected(self, bad_site): + get_settings().set("JIRA.JIRA_SITE", bad_site) + get_settings().set("JIRA.JIRA_API_EMAIL", "me@acme.com") + get_settings().set("JIRA.JIRA_API_TOKEN", "token123") + # No client is built, and no base URL is produced for an invalid site. + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls: + assert _get_jira_client() is None + jira_cls.assert_not_called() + assert _jira_cloud_base_url() is None + + def test_valid_site_only_ever_targets_atlassian_net(self): + """Any accepted site name resolves to a host under .atlassian.net.""" + for good in ("acme", "my-org", "a1b2", "x"): + get_settings().set("JIRA.JIRA_SITE", good) + url = _jira_cloud_base_url() + assert url == f"https://{good}.atlassian.net" + assert urlparse(url).hostname.endswith(".atlassian.net") + + +class TestGetPrTitle: + """Provider-agnostic title access (GitHub/Bitbucket use .pr, GitLab uses .mr).""" + + def test_reads_pr_title(self): + gp = MagicMock(spec=["pr"]) + gp.pr = MagicMock(title="From PR object") + assert _get_pr_title(gp) == "From PR object" + + def test_reads_mr_title_when_no_pr(self): + """GitLab stores the merge request as .mr, not .pr.""" + gp = MagicMock(spec=["mr"]) + gp.mr = MagicMock(title="From MR object") + assert _get_pr_title(gp) == "From MR object" + + def test_returns_empty_when_no_title(self): + gp = MagicMock(spec=[]) + assert _get_pr_title(gp) == "" + + +class TestAddJiraTickets: + """Provider-agnostic Jira append used by extract_tickets for every provider.""" + + def _provider(self, title="", description="", branch=""): + gp = MagicMock(spec=["pr", "get_user_description", "get_pr_branch"]) + gp.pr = MagicMock(title=title) + gp.get_user_description.return_value = description + gp.get_pr_branch.return_value = branch + return gp + + def _configure_jira(self): + get_settings().set("JIRA.JIRA_SITE", "acme") + get_settings().set("JIRA.JIRA_API_EMAIL", "me@acme.com") + get_settings().set("JIRA.JIRA_API_TOKEN", "token123") + get_settings().set("JIRA.JIRA_REQUIREMENTS_FIELD", "") + + def test_appends_ticket_from_any_provider(self): + """Works off get_user_description + get_pr_branch, so it is provider-neutral.""" + self._configure_jira() + client = MagicMock() + client.issue.return_value = {"fields": {"summary": "T", "description": "B", "labels": []}} + gp = self._provider(branch="feature/ABC-123-x") + out = [] + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + add_jira_tickets(gp, out) + assert [t["ticket_id"] for t in out] == ["ABC-123"] + + def test_dedupes_against_existing_tickets(self): + """A Jira ticket already present (same url) is not added twice.""" + self._configure_jira() + client = MagicMock() + client.issue.return_value = {"fields": {"summary": "T", "description": "B", "labels": []}} + gp = self._provider(title="ABC-123") + existing = [{"ticket_url": "https://acme.atlassian.net/browse/ABC-123"}] + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + add_jira_tickets(gp, existing) + assert len(existing) == 1 + + def test_noop_when_jira_not_configured(self): + get_settings().set("JIRA.JIRA_SITE", "") + get_settings().set("JIRA.JIRA_API_TOKEN", "") + gp = self._provider(branch="feature/ABC-123-x") + out = [] + add_jira_tickets(gp, out) + assert out == [] + + def test_respects_overall_cap_with_existing_tickets(self): + """MAX_TICKETS is the combined per-PR cap: provider-native tickets already in + tickets_content count against it, and Jira is appended only up to the cap.""" + self._configure_jira() + client = MagicMock() + client.issue.return_value = {"fields": {"summary": "T", "description": "B", "labels": []}} + # Pre-fill with (MAX_TICKETS - 1) provider-native tickets, then offer several Jira keys. + existing = [{"ticket_url": f"https://example/issues/{i}"} for i in range(MAX_TICKETS - 1)] + gp = self._provider(description="ABC-1 DEF-2 GHI-3 JKL-4") + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + add_jira_tickets(gp, existing) + assert len(existing) == MAX_TICKETS # only one Jira ticket was appended + + def test_skips_jira_entirely_when_cap_already_reached(self): + """When provider-native tickets already fill the cap, no Jira client is built.""" + self._configure_jira() + existing = [{"ticket_url": f"https://example/issues/{i}"} for i in range(MAX_TICKETS)] + gp = self._provider(description="ABC-1 DEF-2") + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls: + add_jira_tickets(gp, existing) + jira_cls.assert_not_called() + assert len(existing) == MAX_TICKETS + + +class TestAzureRequirementsTruncation: + """The Azure DevOps branch caps acceptance criteria like the body (no unbounded blob).""" + + @pytest.mark.asyncio + async def test_acceptance_criteria_truncated(self): + gp = AzureDevopsProvider.__new__(AzureDevopsProvider) # bypass __init__/network + gp.get_linked_work_items = MagicMock(return_value=[{ + "id": 1, + "url": "https://dev.azure.com/org/proj/_workitems/edit/1", + "title": "Work item", + "body": "short body", + "acceptance_criteria": "x" * (MAX_TICKET_CHARACTERS + 50), + "labels": [], + }]) + # Isolate the Azure branch: keep the provider-agnostic Jira step a no-op. + with patch("pr_agent.tools.ticket_pr_compliance_check.add_jira_tickets", + side_effect=lambda gp, tc: tc): + result = await extract_tickets(gp) + assert result[0]["requirements"] == "x" * MAX_TICKET_CHARACTERS + "..." + + @pytest.mark.asyncio + async def test_non_string_acceptance_criteria_becomes_empty(self): + gp = AzureDevopsProvider.__new__(AzureDevopsProvider) + gp.get_linked_work_items = MagicMock(return_value=[{ + "id": 2, "url": "u", "title": "t", "body": "b", + "acceptance_criteria": {"unexpected": "dict"}, "labels": [], + }]) + with patch("pr_agent.tools.ticket_pr_compliance_check.add_jira_tickets", + side_effect=lambda gp, tc: tc): + result = await extract_tickets(gp) + assert result[0]["requirements"] == "" diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index d3a5e2a779..30801e9462 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -362,6 +362,41 @@ async def test_extract_tickets_caps_non_github_asana_references(self): for ticket in tickets ) + @pytest.mark.asyncio + async def test_fallback_asana_tickets_count_against_jira_budget(self, monkeypatch): + """The fallback path shares MAX_TICKETS between Asana and Jira results, + matching the provider-specific paths that pass their existing tickets in.""" + jira_keys = ["jira.jira_site", "jira.jira_api_email", "jira.jira_api_token"] + snapshot = _settings_helpers.snapshot_settings(jira_keys) + try: + get_settings().set("jira.jira_site", "example") + get_settings().set("jira.jira_api_email", "user@example.com") + get_settings().set("jira.jira_api_token", "test-token") + + class _FakeJira: + def issue(self, key): + return {"fields": {"summary": f"Ticket {key}", "description": "body", + "labels": []}} + + monkeypatch.setattr(tpc, "Jira", lambda **kwargs: _FakeJira()) + provider = _GenericProvider( + "Related Asana tasks: " + "https://app.asana.com/0/99/111111111111 " + "https://app.asana.com/0/99/222222222222 " + "https://app.asana.com/0/99/333333333333 " + "Also PROJ-1 PROJ-2 PROJ-3 PROJ-4 PROJ-5" + ) + + tickets = await tpc.extract_tickets(provider) + + assert len(tickets) == tpc.MAX_TICKETS + assert [t["ticket_id"] for t in tickets[:3]] == [ + "111111111111", "222222222222", "333333333333" + ] + assert [t["ticket_id"] for t in tickets[3:]] == ["PROJ-1", "PROJ-2"] + finally: + _settings_helpers.restore_settings(snapshot) + @pytest.mark.asyncio async def test_extract_tickets_adds_asana_without_truncating_azure(self): """Azure work items remain intact while Asana additions keep their own cap.""" From c810eff127656376b4d29dbef6682f20cf453b04 Mon Sep 17 00:00:00 2001 From: Mostafa Hassanzadeh Date: Fri, 11 Sep 2026 14:16:30 +0330 Subject: [PATCH 069/238] fix: build real Bitbucket Server language map and ignore non-numeric language entries (#2985) Co-authored-by: IsmaelMartinez Co-authored-by: Claude Opus 5 (1M context) --- pr_agent/algo/language_handler.py | 9 ++++++++ .../bitbucket_server_provider.py | 18 +++++++++++++-- pr_agent/git_providers/git_provider.py | 4 ++++ tests/unittest/test_bitbucket_provider.py | 18 +++++++++++++++ tests/unittest/test_language_handler.py | 22 +++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/pr_agent/algo/language_handler.py b/pr_agent/algo/language_handler.py index dee3b8a481..9d2e0ec9e3 100644 --- a/pr_agent/algo/language_handler.py +++ b/pr_agent/algo/language_handler.py @@ -77,10 +77,19 @@ def get_language(filename: str) -> str | None: return get_language +def numeric_languages(languages: Dict) -> Dict: + """ + Keep only the {language: size} entries of a provider's get_languages() result. PyGithub 2.x + adds a "url" string to every GET dict it returns, and a str cannot be ranked against sizes. + """ + return {k: v for k, v in (languages or {}).items() if isinstance(v, (int, float))} + + def sort_files_by_main_languages(languages: Dict, files: list): """ Sort files by their main language, put the files that are in the main language first and the rest files after """ + languages = numeric_languages(languages) # sort languages by their size languages_sorted_list = [k for k, v in sorted(languages.items(), key=lambda item: item[1], reverse=True)] # languages_sorted = sorted(languages, key=lambda x: x[1], reverse=True) diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index 26707a25a1..c3ae3f377a 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -2,6 +2,7 @@ import re import shlex import subprocess +from collections import Counter from types import SimpleNamespace from typing import Optional, Tuple from urllib.parse import quote_plus, urlparse @@ -12,7 +13,7 @@ from ..algo.file_filter import filter_ignored from ..algo.git_patch_processing import decode_if_bytes -from ..algo.language_handler import is_valid_file +from ..algo.language_handler import build_language_file_matcher, is_valid_file from ..algo.types import EDIT_TYPE, FilePatchInfo from ..algo.utils import find_line_number_of_relevant_line_in_file, load_large_diff from ..config_loader import get_settings, get_verbosity_level @@ -525,7 +526,20 @@ def get_title(self): return self.pr.title def get_languages(self): - return {"yaml": 0} # devops LOL + # Return {language name: percentage}, like the other providers. + lang_map = get_settings().get("language_extension_map_org", {}) or {} + get_language = build_language_file_matcher(lang_map) + + lang_count = Counter() + for filename in self.get_files(): + if not filename: + continue + language = get_language(filename) + if language: + lang_count[language] += 1 + + total = sum(lang_count.values()) or 1 + return {lang: count / total * 100 for lang, count in lang_count.items()} def get_pr_branch(self): return self.pr.fromRef['displayId'] diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index b33c07a066..632565f531 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -7,6 +7,7 @@ from collections.abc import Iterable from typing import Optional, Tuple +from pr_agent.algo.language_handler import numeric_languages from pr_agent.algo.types import FilePatchInfo from pr_agent.algo.utils import ( Range, @@ -865,6 +866,9 @@ def get_main_pr_language(languages, files) -> str: return main_language_str try: + languages = numeric_languages(languages) + if not languages: + return main_language_str top_language = max(languages, key=languages.get).lower() # validate that the specific commit uses the main language diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index 3105921480..21a4110132 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -1157,6 +1157,24 @@ def test_get_repo_file_content_propagates_non_404_errors(self): with pytest.raises(HTTPError, match="500 Internal Server Error"): provider.get_repo_file_content("AGENTS.md") + def test_get_languages_returns_language_names(self): + # get_languages() must key on language NAMES (e.g. "Python"), not raw + # extensions ("py"): sort_files_by_main_languages() maps names back to + # extensions, so extension keys would drop every file into "Other". + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider.get_files = MagicMock(return_value=["a.py", "b.py", "c.py", "d.js", "weird.zzz"]) + + languages = provider.get_languages() + + # 3 Python + 1 JavaScript known; .zzz is unknown and excluded from the total. + assert languages == {"Python": 75.0, "JavaScript": 25.0} + + def test_get_languages_returns_empty_map_when_nothing_matches(self): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider.get_files = MagicMock(return_value=["weird.zzz", ""]) + + assert provider.get_languages() == {} + def _make_provider_for_repo_settings(self, get_content_side_effect): # Bypass __init__ (which performs live API calls) and only wire up the # attributes get_repo_settings() relies on. diff --git a/tests/unittest/test_language_handler.py b/tests/unittest/test_language_handler.py index 400bd21d4f..fcc185dbe6 100644 --- a/tests/unittest/test_language_handler.py +++ b/tests/unittest/test_language_handler.py @@ -64,6 +64,28 @@ def test_edge_case_empty_languages(self): expected_output = [{'language': 'Other', 'files': files}] assert sort_files_by_main_languages(languages, files) == expected_output + # PyGithub 2.x adds a "url" string to the languages dict; it must not take part in the ranking + def test_non_numeric_language_entries_are_ignored(self): + languages = {'Python': 10, 'Java': 5, 'url': 'https://api.github.com/repos/o/r/languages'} + files = [ + type('', (object,), {'filename': 'file1.py'})(), + type('', (object,), {'filename': 'file2.java'})() + ] + expected_output = [ + {'language': 'Python', 'files': [files[0]]}, + {'language': 'Java', 'files': [files[1]]}, + {'language': 'Other', 'files': []} + ] + assert sort_files_by_main_languages(languages, files) == expected_output + + def test_main_pr_language_ignores_non_numeric_entries(self): + from pr_agent.git_providers.git_provider import get_main_pr_language + + languages = {'Python': 10, 'Java': 5, 'url': 'https://api.github.com/repos/o/r/languages'} + files = [type('', (object,), {'filename': 'file1.py'})()] + assert get_main_pr_language(languages, files) == 'python' + assert get_main_pr_language({'url': 'https://api.github.com/repos/o/r/languages'}, files) == '' + # Tests that function handles empty files list def test_edge_case_empty_files(self): languages = {'Python': 10, 'Java': 5} From ca725b1ba511d82930d372ed9d79d9e95da23262 Mon Sep 17 00:00:00 2001 From: nb213 Date: Fri, 11 Sep 2026 21:49:40 +0800 Subject: [PATCH 070/238] docs: add an Atlas Cloud section to changing_a_model (#3037) Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- docs/docs/usage-guide/changing_a_model.md | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 73ea823eac..20fd9b97c5 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -722,6 +722,37 @@ Authentication uses the [GitHub Copilot provider](https://docs.litellm.ai/docs/p Whether Copilot's terms permit this programmatic use is a question for GitHub rather than a guarantee this project can make, so confirm before relying on the route. +### Atlas Cloud + +[Atlas Cloud](https://www.atlascloud.ai) is an OpenAI-compatible inference platform. It needs no provider-specific code in PR-Agent: the `openai/` prefix routes the request to Atlas's base URL through litellm's OpenAI-compatible path, the same way [OrcaRouter](#orcarouter) and the [Neon AI Gateway](#neon-ai-gateway) are handled. + +To use a model served by Atlas Cloud, set: + +```toml +[config] # in configuration.toml +model = "openai/deepseek-ai/deepseek-v4-pro" +fallback_models = ["openai/deepseek-ai/deepseek-v4-pro"] +custom_model_max_tokens = 1048000 # the context window Atlas publishes for the model + +[openai] # in .secrets.toml +api_base = "https://api.atlascloud.ai/v1" +key = "..." # your Atlas Cloud api key +``` + +or use the environment variables (make sure to use double underscores `__`): + +```bash +OPENAI__API_BASE=https://api.atlascloud.ai/v1 +OPENAI__KEY=... +``` + +(you can obtain an Atlas Cloud API key from the [console](https://www.atlascloud.ai/console)) + +Keep the `openai/` prefix on the model name, whichever Atlas model ID you use (`openai/deepseek-ai/deepseek-v4-pro`, `openai/zai-org/glm-5`, `openai/moonshotai/kimi-k2.6`, ...): the prefix routes the request through litellm's OpenAI-compatible path. A prefixed name is not in the `MAX_TOKENS` table [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), so you also have to set `custom_model_max_tokens`. Take the value from Atlas's [model catalog](https://www.atlascloud.ai/models). + +!!! note "Reasoning models need output headroom" + Several Atlas models are reasoning models that spend completion tokens on a hidden chain of thought before writing the answer. `deepseek-ai/deepseek-v4-pro` with `max_tokens = 16` returns `finish_reason = "length"` and an **empty** `message.content` — all 16 completion tokens were reasoning tokens. If a tool comes back blank, raise the output budget rather than assuming the request failed. Non-reasoning IDs such as `deepseek-ai/DeepSeek-V3.1` are unaffected. + ### Custom models If the relevant model doesn't appear [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), you can still use it as a custom model: From de2ed28478cb37c047751efb20334f4bf65af1a4 Mon Sep 17 00:00:00 2001 From: Swayam Gupta <77153230+swayamg20@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:22:56 +0530 Subject: [PATCH 071/238] docs(review): correct ticket-compliance documentation (#3330) --- docs/docs/tools/review.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/docs/tools/review.md b/docs/docs/tools/review.md index 2d643ff645..9c64269d60 100644 --- a/docs/docs/tools/review.md +++ b/docs/docs/tools/review.md @@ -154,7 +154,11 @@ for the authoritative default values. - + @@ -213,11 +217,15 @@ for the authoritative default values. !!! tip "" - The `review` can tool automatically add labels to your Pull Requests: + The `review` tool can automatically add labels to your Pull Requests: - **`possible security issue`**: This label is applied if the tool detects a potential [security vulnerability](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L134) in the PR's code. This feedback is controlled by the 'enable_review_labels_security' flag (default is true). - **`review effort [x/5]`**: This label estimates the [effort](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L118) required to review the PR on a relative scale of 1 to 5, where 'x' represents the assessed effort. This feedback is controlled by the 'enable_review_labels_effort' flag (default is true). - - **`ticket compliance`**: Adds a label indicating code compliance level ("Fully compliant" | "PR Code Verified" | "Partially compliant" | "Not compliant") to any GitHub/Jira/Linea ticket linked in the PR. Controlled by the 'require_ticket_labels' flag (default: false). If 'require_no_ticket_labels' is also enabled, PRs without ticket links will receive a "No ticket found" label. + + Ticket compliance is reported in the review comment, not as a PR label. It is controlled by + `pr_reviewer.require_ticket_analysis_review` and requires available ticket context. The tool does not add + ticket-compliance labels or a "No ticket found" label. See + [Fetching ticket context](../core-abilities/fetching_ticket_context.md) for linking and authentication details. ### Auto-blocking PRs from being merged based on the generated labels @@ -226,7 +234,7 @@ for the authoritative default values. You can configure a CI/CD Action to prevent merging PRs with specific labels. For example, implement a dedicated [GitHub Action](https://medium.com/sequra-tech/quick-tip-block-pull-request-merge-using-labels-6cc326936221). - This approach helps ensure PRs with potential security issues or ticket compliance problems will not be merged without further review. + This approach helps ensure PRs with potential security issues will not be merged without further review. Since AI may make mistakes or lack complete context, use this feature judiciously. For flexibility, users with appropriate permissions can remove generated labels when necessary. When a label is removed, this action will be automatically documented in the PR discussion, clearly indicating it was a deliberate override by an authorized user to allow the merge. From fe771a15aaf56043d8fbc6c48639ba4ef3c37b0e Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Sat, 12 Sep 2026 17:55:23 -0500 Subject: [PATCH 072/238] fix(add_docs): align prompt example field (#3325) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/settings/pr_add_docs.toml | 2 +- tests/unittest/test_pr_add_docs_prompt_contract.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/unittest/test_pr_add_docs_prompt_contract.py diff --git a/pr_agent/settings/pr_add_docs.toml b/pr_agent/settings/pr_add_docs.toml index 8efb8b945b..e9fdc41e43 100644 --- a/pr_agent/settings/pr_add_docs.toml +++ b/pr_agent/settings/pr_add_docs.toml @@ -56,7 +56,7 @@ Example output: Code Documentation: - relevant file: |- src/file1.py - relevant lines: 12 + relevant line: 12 doc placement: after documentation: |- \"\"\" diff --git a/tests/unittest/test_pr_add_docs_prompt_contract.py b/tests/unittest/test_pr_add_docs_prompt_contract.py new file mode 100644 index 0000000000..bb63c5c8d9 --- /dev/null +++ b/tests/unittest/test_pr_add_docs_prompt_contract.py @@ -0,0 +1,14 @@ +import yaml + +from pr_agent.config_loader import get_settings + + +def test_add_docs_example_uses_singular_relevant_line(): + prompt = get_settings().pr_add_docs_prompt.system + example_section = prompt.partition("Example output:")[2] + example = example_section.partition("```yaml")[2].partition("```")[0] + + documentation = yaml.safe_load(example)["Code Documentation"][0] + + assert documentation["relevant line"] == 12 + assert "relevant lines" not in documentation From 6ac10ea8e2362eb8ac4b2c29e8a657da9c2482d7 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Sat, 12 Sep 2026 17:57:33 -0500 Subject: [PATCH 073/238] fix(help): fit prompts for each attempted model (#3308) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- .../algo/ai_handlers/litellm_ai_handler.py | 177 +++++++--- pr_agent/tools/pr_help_message.py | 175 ++++++++-- .../test_litellm_chat_completion_core.py | 16 + .../test_litellm_max_output_tokens.py | 243 +++++++++++++- .../unittest/test_pr_help_message_fallback.py | 315 +++++++++++++++++- 5 files changed, 854 insertions(+), 72 deletions(-) diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 204c477918..22e06a49a8 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -3420,12 +3420,6 @@ def _as_bool(value, default=True): return value.strip().lower() in ("1", "true", "yes", "on") return default - def _as_int(value): - try: - return int(value) - except (TypeError, ValueError): - return 0 - provider_only = _as_list(openrouter_settings.get("provider_only", [])) provider_order = _as_list(openrouter_settings.get("provider_order", [])) if provider_only: @@ -3439,7 +3433,7 @@ def _as_int(value): effective_reasoning_effort = str( openrouter_settings.get("reasoning_effort", "") or "" ).strip().lower() - reasoning_max_tokens = _as_int(openrouter_settings.get("reasoning_max_tokens", 0)) + reasoning_max_tokens = self._coerce_token_value(openrouter_settings.get("reasoning_max_tokens", 0)) if effective_reasoning_effort: try: ReasoningEffort(effective_reasoning_effort) @@ -3500,12 +3494,15 @@ def _as_int(value): if extra_body: kwargs["extra_body"] = extra_body - max_tokens = _as_int(openrouter_settings.get("max_tokens", 0)) + max_tokens = self._coerce_token_value(openrouter_settings.get("max_tokens", 0)) + output_limit_param = ( + "max_completion_tokens" if "max_completion_tokens" in kwargs else "max_tokens" + ) if max_tokens > 0: - existing = _as_int(kwargs.get("max_tokens", 0)) - kwargs["max_tokens"] = min(existing, max_tokens) if existing > 0 else max_tokens - effective_max_tokens = _as_int(kwargs.get("max_tokens", 0)) - effective_reasoning_max_tokens = _as_int(reasoning.get("max_tokens", 0)) + existing = self._coerce_token_value(kwargs.get(output_limit_param, 0)) + kwargs[output_limit_param] = min(existing, max_tokens) if existing > 0 else max_tokens + effective_max_tokens = self._coerce_token_value(kwargs.get(output_limit_param, 0)) + effective_reasoning_max_tokens = self._coerce_token_value(reasoning.get("max_tokens", 0)) effective_reasoning_effort = reasoning.get("effort") if ( model.startswith("openrouter/anthropic/") @@ -3522,6 +3519,118 @@ def _as_int(value): ) return kwargs + @staticmethod + def _coerce_token_value(value) -> int: + """Mirror the request's permissive integer coercion without conversion failures.""" + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return 0 + + def _claude_thinking_mode(self, model: str) -> str | None: + """Return the thinking mode selected by request construction for this model.""" + adaptive_model = self._is_claude_adaptive_thinking_model(model) + if adaptive_model and self._claude_thinking_controls["enable_claude_adaptive_thinking"]: + return "adaptive" + if ( + model in self.claude_extended_thinking_models + and self._claude_thinking_controls["enable_claude_extended_thinking"] + ): + return "unsupported_extended" if adaptive_model else "extended" + return None + + def _get_claude_extended_thinking_limits(self) -> tuple[int, int]: + """Validate and return the snapshotted extended-thinking budget and output cap.""" + extended_thinking_budget_tokens = self._claude_thinking_controls["extended_thinking_budget_tokens"] + extended_thinking_max_output_tokens = self._claude_thinking_controls["extended_thinking_max_output_tokens"] + + if not isinstance(extended_thinking_budget_tokens, int) or extended_thinking_budget_tokens <= 0: + raise ValueError( + f"extended_thinking_budget_tokens must be a positive integer, " + f"got {extended_thinking_budget_tokens}" + ) + if not isinstance(extended_thinking_max_output_tokens, int) or extended_thinking_max_output_tokens <= 0: + raise ValueError( + f"extended_thinking_max_output_tokens must be a positive integer, " + f"got {extended_thinking_max_output_tokens}" + ) + if extended_thinking_max_output_tokens < extended_thinking_budget_tokens: + raise ValueError( + f"extended_thinking_max_output_tokens ({extended_thinking_max_output_tokens}) must be greater than " + f"or equal to extended_thinking_budget_tokens ({extended_thinking_budget_tokens})" + ) + return extended_thinking_budget_tokens, extended_thinking_max_output_tokens + + def _resolve_output_token_limit(self, model: str, openrouter_model: str | None) -> int: + """Return the final positive output cap selected by PR-Agent request controls.""" + output_tokens = self._coerce_token_value(get_settings().config.get("max_output_tokens", 0)) + if self._claude_thinking_mode(model) == "extended": + _, output_tokens = self._get_claude_extended_thinking_limits() + + if openrouter_model: + openrouter_output_tokens = self._coerce_token_value(self._openrouter_controls.get("max_tokens", 0)) + if openrouter_output_tokens > 0: + output_tokens = ( + min(output_tokens, openrouter_output_tokens) + if output_tokens > 0 + else openrouter_output_tokens + ) + return output_tokens if output_tokens > 0 else 0 + + def get_output_token_limit(self, model: str) -> int: + """Return the output cap that this handler will request for the supplied model.""" + custom_llm_provider = self._custom_llm_provider + configured_deployment_id = self.deployment_id + routed_model = self._route_model_for_request(model, custom_llm_provider, configured_deployment_id) + completion_model = self._normalize_gpt5_model_for_request(routed_model, model, custom_llm_provider) + request_provider = ( + PROVIDER_SETTING_ALIASES.get(custom_llm_provider, custom_llm_provider) + if custom_llm_provider + else self._resolve_request_provider(routed_model) + ) + openrouter_model = self._canonical_openrouter_model(completion_model, request_provider) + return self._resolve_output_token_limit(completion_model, openrouter_model) + + def get_output_token_reserve(self, model: str, default_output_tokens: int) -> int: + """Return completion headroom to reserve while fitting a request prompt.""" + output_tokens = self.get_output_token_limit(model) + if output_tokens > 0: + return output_tokens + + default_output_tokens = self._coerce_token_value(default_output_tokens) + custom_llm_provider = self._custom_llm_provider + routed_model = self._route_model_for_request(model, custom_llm_provider, self.deployment_id) + request_provider = ( + PROVIDER_SETTING_ALIASES.get(custom_llm_provider, custom_llm_provider) + if custom_llm_provider + else self._resolve_request_provider(routed_model) + ) + openrouter_model = self._canonical_openrouter_model( + routed_model, request_provider + ) + if not openrouter_model: + return default_output_tokens + + reasoning_effort = str( + self._openrouter_controls.get("reasoning_effort", "") or "" + ).strip().lower() + reasoning_effort = self._clamp_grok_reasoning_effort( + openrouter_model, reasoning_effort + ) + reasoning_tokens = self._coerce_token_value( + self._openrouter_controls.get("reasoning_max_tokens", 0) + ) + if reasoning_effort == "none" or reasoning_tokens <= 0: + return default_output_tokens + return default_output_tokens + reasoning_tokens + + @staticmethod + def normalize_request_prompts(model: str, system_prompt: str, user_prompt: str) -> tuple[str, str]: + """Return the prompt strings that request construction will send.""" + if 'claude' in model and not system_prompt: + system_prompt = "No system prompt provided" + return system_prompt, user_prompt + def _configure_claude_extended_thinking(self, model: str, kwargs: dict) -> dict: """ Configure Claude extended thinking parameters if applicable. @@ -3533,16 +3642,9 @@ def _configure_claude_extended_thinking(self, model: str, kwargs: dict) -> dict: Returns: dict: Updated kwargs with extended thinking configuration """ - extended_thinking_budget_tokens = self._claude_thinking_controls["extended_thinking_budget_tokens"] - extended_thinking_max_output_tokens = self._claude_thinking_controls["extended_thinking_max_output_tokens"] - - # Validate extended thinking parameters - if not isinstance(extended_thinking_budget_tokens, int) or extended_thinking_budget_tokens <= 0: - raise ValueError(f"extended_thinking_budget_tokens must be a positive integer, got {extended_thinking_budget_tokens}") - if not isinstance(extended_thinking_max_output_tokens, int) or extended_thinking_max_output_tokens <= 0: - raise ValueError(f"extended_thinking_max_output_tokens must be a positive integer, got {extended_thinking_max_output_tokens}") - if extended_thinking_max_output_tokens < extended_thinking_budget_tokens: - raise ValueError(f"extended_thinking_max_output_tokens ({extended_thinking_max_output_tokens}) must be greater than or equal to extended_thinking_budget_tokens ({extended_thinking_budget_tokens})") + extended_thinking_budget_tokens, extended_thinking_max_output_tokens = ( + self._get_claude_extended_thinking_limits() + ) kwargs["thinking"] = { "type": "enabled", @@ -3808,10 +3910,11 @@ async def _chat_completion_with_retry( # prefixes must remain intact in multi-provider configurations. model = completion_model openrouter_model = self._canonical_openrouter_model(model, request_provider) - if 'claude' in model and not system: - system = "No system prompt provided" + normalized_system, user = self.normalize_request_prompts(model, system, user) + if normalized_system != system: get_logger().warning( "Empty system prompt for claude model. Adding a newline character to prevent OpenAI API error.") + system = normalized_system messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] if img_path: @@ -3938,19 +4041,16 @@ async def _chat_completion_with_retry( # https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking adaptive_thinking_enabled = self._claude_thinking_controls["enable_claude_adaptive_thinking"] extended_thinking_enabled = self._claude_thinking_controls["enable_claude_extended_thinking"] - if self._is_claude_adaptive_thinking_model(model) and adaptive_thinking_enabled: + claude_thinking_mode = self._claude_thinking_mode(model) + if claude_thinking_mode == "adaptive": kwargs = self._configure_claude_adaptive_thinking(model, kwargs) - elif ( - model in self.claude_extended_thinking_models - and extended_thinking_enabled - ): - if self._is_claude_adaptive_thinking_model(model): - get_logger().warning( - f"Skipping extended thinking for {model}: adaptive-only models reject " - f"budget_tokens. Enable config.enable_claude_adaptive_thinking instead." - ) - else: - kwargs = self._configure_claude_extended_thinking(model, kwargs) + elif claude_thinking_mode == "extended": + kwargs = self._configure_claude_extended_thinking(model, kwargs) + elif claude_thinking_mode == "unsupported_extended": + get_logger().warning( + f"Skipping extended thinking for {model}: adaptive-only models reject " + f"budget_tokens. Enable config.enable_claude_adaptive_thinking instead." + ) elif adaptive_thinking_enabled or extended_thinking_enabled: message = ( f"No thinking configuration applied for model {model}: adaptive thinking " @@ -3968,10 +4068,7 @@ async def _chat_completion_with_retry( # providers apply a low service-side default (Bedrock Converse: 4096, # which reasoning can fully consume, returning empty content). # setdefault keeps the extended-thinking limit authoritative. - try: - max_output_tokens = int(get_settings().config.get("max_output_tokens", 0)) - except (TypeError, ValueError): - max_output_tokens = 0 + max_output_tokens = self._resolve_output_token_limit(model, openrouter_model) if max_output_tokens > 0: output_limit_param = "max_completion_tokens" if is_gpt6_astra else "max_tokens" kwargs.setdefault(output_limit_param, max_output_tokens) diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 75942c2f31..5fc78d999a 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -1,21 +1,27 @@ import copy import re from functools import partial +from math import ceil, isfinite from pathlib import Path from jinja2 import Environment, StrictUndefined, select_autoescape +from litellm import token_counter from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.pr_processing import retry_with_fallback_models -from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import ModelType, clip_tokens, get_max_tokens, load_yaml +from pr_agent.algo.token_handler import TokenEncoder +from pr_agent.algo.utils import ModelType, get_max_tokens, load_yaml from pr_agent.command_descriptions import COMMAND_DESCRIPTIONS from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider_with_context from pr_agent.log import get_logger DOCS_SITE_URL = "https://docs.pr-agent.ai" +HELP_OUTPUT_TOKEN_RESERVE = 2_000 +MESSAGE_FRAMING_TOKEN_ALLOWANCE = 16 +REPLY_FRAMING_TOKEN_ALLOWANCE = 16 +TRUNCATION_MARKER = "\n...(truncated)\n" class PRHelpMessage: @@ -29,13 +35,16 @@ def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = "question": self.question_str, "snippets": "", } - self.token_handler = TokenHandler(None, - self.vars, - get_settings().pr_help_prompts.system, - get_settings().pr_help_prompts.user) async def _prepare_prediction(self, model: str): variables = copy.deepcopy(self.vars) + system_prompt, user_prompt = self._fit_prompts(variables, model) + response, finish_reason = await self.ai_handler.chat_completion( + model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) + return response + + @staticmethod + def _render_prompts(variables): # These string templates produce plain-text model prompts, not HTML. environment = Environment( autoescape=select_autoescape(default_for_string=False), @@ -43,9 +52,147 @@ async def _prepare_prediction(self, model: str): ) system_prompt = environment.from_string(get_settings().pr_help_prompts.system).render(variables) user_prompt = environment.from_string(get_settings().pr_help_prompts.user).render(variables) - response, finish_reason = await self.ai_handler.chat_completion( - model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) - return response + return system_prompt, user_prompt + + @staticmethod + def _coerce_output_token_limit(value) -> int: + try: + output_tokens = int(value) + except (TypeError, ValueError, OverflowError): + return 0 + return output_tokens if output_tokens > 0 else 0 + + def _get_prompt_budget(self, model: str) -> int: + output_tokens = 0 + get_output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + if callable(get_output_token_reserve): + try: + handler_output_tokens = get_output_token_reserve(model, HELP_OUTPUT_TOKEN_RESERVE) + except Exception as e: + get_logger().debug(f"Failed to resolve the output token reserve for {model}: {e}") + else: + if ( + isinstance(handler_output_tokens, int) + and not isinstance(handler_output_tokens, bool) + and handler_output_tokens > 0 + ): + output_tokens = handler_output_tokens + get_output_token_limit = getattr(self.ai_handler, "get_output_token_limit", None) + if output_tokens <= 0 and callable(get_output_token_limit): + try: + handler_output_tokens = get_output_token_limit(model) + except Exception as e: + get_logger().debug(f"Failed to resolve the output token limit for {model}: {e}") + else: + if ( + isinstance(handler_output_tokens, int) + and not isinstance(handler_output_tokens, bool) + and handler_output_tokens > 0 + ): + output_tokens = handler_output_tokens + if output_tokens <= 0: + raw_output_tokens = get_settings().config.get("max_output_tokens", 0) + output_tokens = self._coerce_output_token_limit(raw_output_tokens) + if output_tokens <= 0: + output_tokens = HELP_OUTPUT_TOKEN_RESERVE + return max(get_max_tokens(model, ignore_max_model_tokens=True) - output_tokens, 0) + + @staticmethod + def _count_prompt_tokens(model: str, system_prompt: str, user_prompt: str) -> int: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + try: + model_token_count = token_counter(model=model, messages=messages) + if isinstance(model_token_count, int) and not isinstance(model_token_count, bool) and model_token_count > 0: + return model_token_count + except Exception as e: + get_logger().debug(f"Model-aware token counting failed for {model}: {e}") + + get_logger().debug(f"Using a local token estimate for {model}") + encoder = TokenEncoder.get_token_encoder(model) + content_tokens = sum( + len(encoder.encode(message["content"], disallowed_special=())) for message in messages + ) + framing_tokens = MESSAGE_FRAMING_TOKEN_ALLOWANCE * len(messages) + REPLY_FRAMING_TOKEN_ALLOWANCE + raw_factor = get_settings().get("config.model_token_count_estimate_factor", 0) + try: + extra_factor = float(raw_factor) + except (TypeError, ValueError, OverflowError): + extra_factor = 0 + if isinstance(raw_factor, bool) or not isfinite(extra_factor): + extra_factor = 0 + multiplier = max(1.0, 1.0 + extra_factor) + raw_estimate = content_tokens + framing_tokens + try: + estimated_tokens = raw_estimate * multiplier + if not isfinite(estimated_tokens): + raise ValueError("non-finite token estimate") + return ceil(estimated_tokens) + except (OverflowError, ValueError): + get_logger().warning( + f"model_token_count_estimate_factor is too large ({raw_factor!r}), using the estimate as is" + ) + return raw_estimate + + def _normalize_request_prompts(self, model: str, system_prompt: str, user_prompt: str) -> tuple[str, str]: + normalize_request_prompts = getattr(self.ai_handler, "normalize_request_prompts", None) + if not callable(normalize_request_prompts): + return system_prompt, user_prompt + try: + normalized_prompts = normalize_request_prompts(model, system_prompt, user_prompt) + except Exception as e: + get_logger().debug(f"Failed to normalize prompts for {model}: {e}") + return system_prompt, user_prompt + if ( + isinstance(normalized_prompts, tuple) + and len(normalized_prompts) == 2 + and all(isinstance(prompt, str) for prompt in normalized_prompts) + ): + return normalized_prompts + get_logger().debug(f"Ignoring unusable prompt normalization result for {model}") + return system_prompt, user_prompt + + def _fit_prompts(self, variables, model: str): + prompt_budget = self._get_prompt_budget(model) + raw_snippets = variables.get("snippets", "") + + def render(snippets): + attempt_variables = copy.deepcopy(variables) + attempt_variables["snippets"] = snippets + rendered_prompts = self._render_prompts(attempt_variables) + return self._normalize_request_prompts(model, *rendered_prompts) + + full_prompts = render(raw_snippets) + if self._count_prompt_tokens(model, *full_prompts) <= prompt_budget: + return full_prompts + + empty_prompts = render("") + if self._count_prompt_tokens(model, *empty_prompts) > prompt_budget: + raise ValueError(f"The /help prompt exceeds the token limit for {model} without documentation") + + marker_prompts = render(TRUNCATION_MARKER) + if self._count_prompt_tokens(model, *marker_prompts) > prompt_budget: + raise ValueError(f"The /help prompt exceeds the token limit for {model} with a truncation marker") + + keep_chars = max(len(raw_snippets) - 1, 0) + while keep_chars > 0: + candidate = raw_snippets[:keep_chars].rstrip() + TRUNCATION_MARKER + candidate_prompts = render(candidate) + candidate_tokens = self._count_prompt_tokens(model, *candidate_prompts) + if candidate_tokens <= prompt_budget: + get_logger().warning( + f"Documentation was clipped for /help to fit the {prompt_budget}-token input limit for {model}" + ) + return candidate_prompts + next_keep_chars = max(1, int(keep_chars * prompt_budget / candidate_tokens)) + keep_chars = min(keep_chars - 1, next_keep_chars) + + get_logger().warning( + f"Documentation was clipped for /help to fit the {prompt_budget}-token input limit for {model}" + ) + return marker_prompts def parse_args(self, args): if args and len(args) > 0: @@ -125,16 +272,6 @@ async def run(self): docs_prompt += f"\n==file name==\n\n{file_path}\n\n==file content==\n\n{f.read().strip()}\n=========\n\n" except Exception as e: get_logger().error(f"Error while reading the file {file}: {e}") - token_count = self.token_handler.count_tokens(docs_prompt) - get_logger().debug(f"Token count of full documentation website: {token_count}") - - # take the actual max tokens, without any reductions. we do aim to get - # the full documentation website in the prompt - max_tokens_full = get_max_tokens(get_settings().config.model, ignore_max_model_tokens=True) - delta_output = 2000 - if token_count > max_tokens_full - delta_output: - get_logger().info(f"Token count {token_count} exceeds the limit {max_tokens_full - delta_output}. Skipping the PR Help message.") - docs_prompt = clip_tokens(docs_prompt, max_tokens_full - delta_output) self.vars['snippets'] = docs_prompt.strip() # run the AI model diff --git a/tests/unittest/test_litellm_chat_completion_core.py b/tests/unittest/test_litellm_chat_completion_core.py index ab0bebec3b..c08c8dcac4 100644 --- a/tests/unittest/test_litellm_chat_completion_core.py +++ b/tests/unittest/test_litellm_chat_completion_core.py @@ -85,6 +85,22 @@ async def test_chat_completion_passes_seed_when_temperature_is_zero(monkeypatch) assert mock_call.call_args.kwargs["seed"] == 123 +@pytest.mark.asyncio +async def test_claude_empty_system_prompt_uses_public_request_normalization(monkeypatch): + monkeypatch.setattr(litellm_handler, "get_settings", FakeSettings) + + with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", new_callable=AsyncMock) as mock_call: + mock_call.return_value = _mock_response() + handler = litellm_handler.LiteLLMAIHandler() + expected_prompts = handler.normalize_request_prompts("claude-sonnet-4-5", "", "usr") + + await handler.chat_completion(model="claude-sonnet-4-5", system="", user="usr") + + messages = mock_call.call_args.kwargs["messages"] + assert expected_prompts == ("No system prompt provided", "usr") + assert (messages[0]["content"], messages[1]["content"]) == expected_prompts + + @pytest.mark.asyncio async def test_chat_completion_probes_images_off_loop_with_timeout(monkeypatch): monkeypatch.setattr(litellm_handler, "get_settings", FakeSettings) diff --git a/tests/unittest/test_litellm_max_output_tokens.py b/tests/unittest/test_litellm_max_output_tokens.py index b20fd4c83b..c7636960f9 100644 --- a/tests/unittest/test_litellm_max_output_tokens.py +++ b/tests/unittest/test_litellm_max_output_tokens.py @@ -59,7 +59,7 @@ def test_fixture_clears_and_restores_ambient_litellm_key(monkeypatch): assert litellm.api_key == "test-ambient-key" -def _make_settings(config_values=None): +def _make_settings(config_values=None, openrouter=None, custom_llm_provider=""): """Minimal settings whose `config.get(key, ...)` serves the given dict.""" config_values = config_values or {} settings_values = { @@ -82,9 +82,12 @@ def get(self, key, default=None): return type("Settings", (), { "config": Config(), "litellm": type("LiteLLM", (), { + "custom_llm_provider": custom_llm_provider, "get": lambda self, key, default=None: default, })(), - "get": lambda self, key, default=None: settings_values.get(key, default), + "get": lambda self, key, default=None: ( + (openrouter or {}) if key == "openrouter" else settings_values.get(key, default) + ), })() @@ -96,22 +99,41 @@ def _mock_response(): return mock -async def _run(monkeypatch, model, config_values): - monkeypatch.setattr(litellm_handler, "get_settings", lambda: _make_settings(config_values)) +async def _run( + monkeypatch, + model, + config_values, + openrouter=None, + custom_llm_provider="", + reserve_default=None, +): + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _make_settings(config_values, openrouter, custom_llm_provider), + ) with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", new_callable=AsyncMock) as mock_call: mock_call.return_value = _mock_response() handler = litellm_handler.LiteLLMAIHandler() + exposed_limit = handler.get_output_token_limit(model) + exposed_reserve = ( + handler.get_output_token_reserve(model, reserve_default) + if reserve_default is not None + else None + ) await handler.chat_completion(model=model, system="sys", user="usr") - return mock_call.call_args[1] + result = (mock_call.call_args[1], exposed_limit) + return (*result, exposed_reserve) if reserve_default is not None else result class TestMaxOutputTokens: @pytest.mark.asyncio async def test_default_sends_no_max_tokens(self, monkeypatch): - kwargs = await _run(monkeypatch, "bedrock/anthropic.claude-sonnet-5-v1:0", {}) + kwargs, exposed_limit = await _run(monkeypatch, "bedrock/anthropic.claude-sonnet-5-v1:0", {}) assert "max_tokens" not in kwargs + assert exposed_limit == 0 @pytest.mark.asyncio @pytest.mark.parametrize("model", [ @@ -119,28 +141,227 @@ async def test_default_sends_no_max_tokens(self, monkeypatch): "gpt-4o", ]) async def test_positive_value_sent_as_max_tokens(self, monkeypatch, model): - kwargs = await _run(monkeypatch, model, {"max_output_tokens": 16000}) + kwargs, exposed_limit = await _run(monkeypatch, model, {"max_output_tokens": 16000}) assert kwargs["max_tokens"] == 16000 + assert exposed_limit == 16000 @pytest.mark.asyncio async def test_extended_thinking_limit_stays_authoritative(self, monkeypatch): - kwargs = await _run(monkeypatch, "claude-3-7-sonnet-20250219", { + kwargs, exposed_limit = await _run(monkeypatch, "claude-3-7-sonnet-20250219", { "max_output_tokens": 16000, "enable_claude_extended_thinking": True, "extended_thinking_budget_tokens": 2048, "extended_thinking_max_output_tokens": 4096, }) assert kwargs["max_tokens"] == 4096 + assert exposed_limit == 4096 assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048} @pytest.mark.asyncio async def test_string_override_is_coerced(self, monkeypatch): # Dynaconf/env overrides can arrive as strings. - kwargs = await _run(monkeypatch, "gpt-4o", {"max_output_tokens": "16000"}) + kwargs, exposed_limit = await _run(monkeypatch, "gpt-4o", {"max_output_tokens": "16000"}) assert kwargs["max_tokens"] == 16000 + assert exposed_limit == 16000 @pytest.mark.asyncio - @pytest.mark.parametrize("value", ["16k", None, 0, -1]) + @pytest.mark.parametrize("value", ["16k", None, 0, -1, float("nan"), float("inf"), "1" * 5_000]) async def test_unset_or_invalid_values_send_nothing(self, monkeypatch, value): - kwargs = await _run(monkeypatch, "gpt-4o", {"max_output_tokens": value}) + kwargs, exposed_limit = await _run(monkeypatch, "gpt-4o", {"max_output_tokens": value}) assert "max_tokens" not in kwargs + assert exposed_limit == 0 + + @pytest.mark.asyncio + @pytest.mark.parametrize(("value", "expected"), [(400.5, 400), (True, 1), (False, 0)]) + async def test_existing_int_coercion_is_preserved(self, monkeypatch, value, expected): + kwargs, exposed_limit = await _run(monkeypatch, "gpt-4o", {"max_output_tokens": value}) + + assert exposed_limit == expected + if expected: + assert kwargs["max_tokens"] == expected + else: + assert "max_tokens" not in kwargs + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("model", "custom_llm_provider"), + [ + ("openrouter/google/gemini-2.5-pro", ""), + ("google/gemini-2.5-pro", "openrouter"), + ], + ) + async def test_openrouter_only_limit_is_exposed_and_sent( + self, monkeypatch, model, custom_llm_provider + ): + kwargs, exposed_limit = await _run( + monkeypatch, + model, + {}, + openrouter={"max_tokens": 16000}, + custom_llm_provider=custom_llm_provider, + ) + + assert exposed_limit == 16000 + assert kwargs["max_tokens"] == 16000 + + @pytest.mark.asyncio + async def test_openrouter_limit_caps_general_limit(self, monkeypatch): + kwargs, exposed_limit = await _run( + monkeypatch, + "openrouter/google/gemini-2.5-pro", + {"max_output_tokens": 16000}, + openrouter={"max_tokens": 4096}, + ) + + assert exposed_limit == 4096 + assert kwargs["max_tokens"] == 4096 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("config_values", "openrouter", "expected_limit", "expected_reserve"), + [ + ({}, {"reasoning_max_tokens": 4096}, 0, 6096), + ({"max_output_tokens": 8000}, {"reasoning_max_tokens": 4096}, 8000, 8000), + ({}, {"reasoning_max_tokens": 4096, "reasoning_effort": "none"}, 0, 2000), + ], + ) + async def test_openrouter_reasoning_expands_only_the_default_reserve( + self, + monkeypatch, + config_values, + openrouter, + expected_limit, + expected_reserve, + ): + kwargs, exposed_limit, exposed_reserve = await _run( + monkeypatch, + "openrouter/google/gemini-2.5-pro", + config_values, + openrouter=openrouter, + reserve_default=2000, + ) + + assert exposed_limit == expected_limit + assert exposed_reserve == expected_reserve + if openrouter.get("reasoning_effort") == "none": + assert kwargs["extra_body"]["reasoning"] == {"enabled": False} + else: + assert kwargs["extra_body"]["reasoning"] == {"max_tokens": 4096} + + @pytest.mark.asyncio + async def test_openrouter_grok_clamped_none_still_reserves_reasoning(self, monkeypatch): + kwargs, exposed_limit, exposed_reserve = await _run( + monkeypatch, + "openrouter/x-ai/grok-4.6", + {}, + openrouter={"reasoning_max_tokens": 8000, "reasoning_effort": "none"}, + reserve_default=2000, + ) + + assert exposed_limit == 0 + assert exposed_reserve == 10000 + assert kwargs["extra_body"]["reasoning"] == {"max_tokens": 8000} + + @pytest.mark.asyncio + async def test_openrouter_astra_uses_one_capped_output_limit(self, monkeypatch): + kwargs, exposed_limit = await _run( + monkeypatch, + "gpt-6-astra", + {"max_output_tokens": 16000}, + openrouter={"max_tokens": 4096}, + custom_llm_provider="openrouter", + ) + + assert exposed_limit == 4096 + assert kwargs["max_completion_tokens"] == 4096 + assert "max_tokens" not in kwargs + + @pytest.mark.asyncio + async def test_openrouter_limit_caps_extended_thinking_limit(self, monkeypatch): + model = "openrouter/anthropic/claude-3-7-sonnet-20250219" + kwargs, exposed_limit = await _run( + monkeypatch, + model, + { + "max_output_tokens": 16000, + "enable_claude_extended_thinking": True, + "extended_thinking_budget_tokens": 1024, + "extended_thinking_max_output_tokens": 4096, + "claude_extended_thinking_models_override": [model], + }, + openrouter={"max_tokens": 2048}, + ) + + assert exposed_limit == 2048 + assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 1024} + assert kwargs["max_tokens"] == 2048 + + @pytest.mark.asyncio + @pytest.mark.parametrize("adaptive_enabled", [True, False]) + async def test_adaptive_only_model_never_uses_legacy_extended_limit( + self, monkeypatch, adaptive_enabled + ): + model = "anthropic/claude-opus-5" + kwargs, exposed_limit = await _run(monkeypatch, model, { + "max_output_tokens": 16000, + "enable_claude_adaptive_thinking": adaptive_enabled, + "enable_claude_extended_thinking": True, + "extended_thinking_budget_tokens": 2048, + "extended_thinking_max_output_tokens": 4096, + "claude_extended_thinking_models_override": [model], + }) + + assert exposed_limit == 16000 + assert kwargs["max_tokens"] == 16000 + if adaptive_enabled: + assert kwargs["thinking"] == {"type": "adaptive"} + else: + assert "thinking" not in kwargs + + @pytest.mark.asyncio + async def test_general_limit_is_live_while_handler_controls_are_snapshots(self, monkeypatch): + model = "openrouter/google/gemini-2.5-pro" + config_values = {"max_output_tokens": 16000} + openrouter = {"max_tokens": 4096} + active_settings = _make_settings(config_values, openrouter) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: active_settings) + handler = litellm_handler.LiteLLMAIHandler() + + config_values["max_output_tokens"] = 2048 + openrouter["max_tokens"] = 1024 + + with patch( + "pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", + new_callable=AsyncMock, + ) as mock_call: + mock_call.return_value = _mock_response() + assert handler.get_output_token_limit(model) == 2048 + await handler.chat_completion(model=model, system="sys", user="usr") + + assert mock_call.call_args.kwargs["max_tokens"] == 2048 + + @pytest.mark.asyncio + async def test_extended_thinking_limit_is_snapshotted_for_accessor_and_request(self, monkeypatch): + model = "claude-3-7-sonnet-20250219" + config_values = { + "enable_claude_extended_thinking": True, + "extended_thinking_budget_tokens": 1024, + "extended_thinking_max_output_tokens": 4096, + } + active_settings = _make_settings(config_values) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: active_settings) + handler = litellm_handler.LiteLLMAIHandler() + + config_values["extended_thinking_budget_tokens"] = 2048 + config_values["extended_thinking_max_output_tokens"] = 8192 + + with patch( + "pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", + new_callable=AsyncMock, + ) as mock_call: + mock_call.return_value = _mock_response() + assert handler.get_output_token_limit(model) == 4096 + await handler.chat_completion(model=model, system="sys", user="usr") + + assert mock_call.call_args.kwargs["thinking"] == {"type": "enabled", "budget_tokens": 1024} + assert mock_call.call_args.kwargs["max_tokens"] == 4096 diff --git a/tests/unittest/test_pr_help_message_fallback.py b/tests/unittest/test_pr_help_message_fallback.py index 9b898dc9cf..c6bb6ec1ed 100644 --- a/tests/unittest/test_pr_help_message_fallback.py +++ b/tests/unittest/test_pr_help_message_fallback.py @@ -1,6 +1,7 @@ """Question-mode help delegates failed attempts to the shared fallback loop.""" import asyncio +from math import ceil from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -28,13 +29,15 @@ def help_tool(tmp_path, monkeypatch): "config.model": PRIMARY, "config.fallback_models": [BACKUP], "config.publish_output": True, + "config.max_output_tokens": 0, + "config.model_token_count_estimate_factor": 0, "model_routing.enable": False, # Keep this regression independent of the separate OpenAI-key gate fix. "openai.key": "test-only-key", "openai.deployment_id": "primary-deployment", "openai.fallback_deployments": ["backup-deployment"], } - snapshot = snapshot_settings([*overrides, "pr_help_prompts.user"]) + snapshot = snapshot_settings([*overrides, "pr_help_prompts.system", "pr_help_prompts.user"]) for key, value in overrides.items(): get_settings().set(key, value) @@ -53,7 +56,7 @@ def help_tool(tmp_path, monkeypatch): tool.question_str = "How do I configure automatic reviews?" tool.return_as_string = False tool.vars = {"question": tool.question_str, "snippets": ""} - tool.token_handler = SimpleNamespace(count_tokens=len) + tool._fixture_doc = doc try: yield tool, details, logger finally: @@ -64,6 +67,10 @@ def attempted_models(tool): return [call.kwargs["model"] for call in tool.ai_handler.chat_completion.await_args_list] +def count_message_characters(*, messages, **_kwargs): + return sum(len(message["content"]) for message in messages) + + async def test_model_prompt_preserves_question_and_documentation_markup(help_tool): tool, _, _ = help_tool question = "What does ` & 'notes'` mean?" @@ -98,6 +105,310 @@ async def test_primary_failure_uses_backup_answer(help_tool): assert get_settings().get("openai.deployment_id") == "primary-deployment" +async def test_each_attempt_fits_complete_prompt_for_its_model(help_tool, monkeypatch): + tool, details, _ = help_tool + tool._fixture_doc.write_text("# Automatic review\n\n" + "A" * 600, encoding="utf-8") + get_settings().set("pr_help_prompts.system", "system") + get_settings().set("pr_help_prompts.user", "question={{ question }}\nsnippets={{ snippets }}") + get_settings().set("config.max_output_tokens", 20) + limits = {PRIMARY: 2_000, BACKUP: 260} + limit_calls = [] + counted_models = [] + + def model_limit(model, ignore_max_model_tokens=False): + limit_calls.append((model, ignore_max_model_tokens)) + return limits[model] + + def model_token_count(*, model, messages): + counted_models.append(model) + character_count = count_message_characters(messages=messages) + return character_count if model == PRIMARY else character_count * 2 + + monkeypatch.setattr(pr_help_message, "get_max_tokens", model_limit) + monkeypatch.setattr(pr_help_message, "token_counter", model_token_count) + tool.ai_handler.chat_completion.side_effect = [RuntimeError("primary unavailable"), (ANSWER, "stop")] + + await tool.run() + + assert attempted_models(tool) == [PRIMARY, BACKUP] + primary_prompt = tool.ai_handler.chat_completion.await_args_list[0].kwargs["user"] + backup_prompt = tool.ai_handler.chat_completion.await_args_list[1].kwargs["user"] + assert "A" * 600 in primary_prompt + assert "...(truncated)" not in primary_prompt + assert "A" * 600 not in backup_prompt + assert "...(truncated)" in backup_prompt + assert "==file name==" in backup_prompt + assert "/tools/review.md" in backup_prompt + assert 2 * (len(backup_prompt) + len("system")) <= limits[BACKUP] - 20 + assert "A" * 600 in tool.vars["snippets"] + assert limit_calls == [(PRIMARY, True), (BACKUP, True)] + assert PRIMARY in counted_models + assert BACKUP in counted_models + assert details.model_used == BACKUP + + +@pytest.mark.parametrize( + ("configured_output", "expected_budget"), + [ + (400, 2_600), + ("400", 2_600), + (0, 1_000), + (-1, 1_000), + (None, 1_000), + ("invalid", 1_000), + (400.0, 2_600), + (400.5, 2_600), + (float("nan"), 1_000), + (float("inf"), 1_000), + (True, 2_999), + (False, 1_000), + ("1" * 5_000, 1_000), + ], +) +def test_prompt_budget_honors_positive_output_limit_or_help_default( + help_tool, monkeypatch, configured_output, expected_budget +): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", configured_output) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == expected_budget + + +def test_prompt_budget_uses_handler_reported_output_limit(help_tool, monkeypatch): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_limit = Mock(return_value=1_600) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 1_400 + tool.ai_handler.get_output_token_limit.assert_called_once_with(PRIMARY) + + +def test_prompt_budget_uses_handler_reported_output_reserve(help_tool, monkeypatch): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_reserve = Mock(return_value=1_600) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 1_400 + tool.ai_handler.get_output_token_reserve.assert_called_once_with(PRIMARY, 2_000) + + +@pytest.mark.parametrize("reported_limit", [None, 0, -1, True, 1.5, "1600"]) +def test_unusable_handler_output_limit_falls_back_to_config( + help_tool, monkeypatch, reported_limit +): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_limit = Mock(return_value=reported_limit) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 2_600 + + +def test_failing_handler_output_limit_falls_back_to_config(help_tool, monkeypatch): + tool, _, logger = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_limit = Mock(side_effect=RuntimeError("unavailable")) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 2_600 + assert any("output token limit" in call.args[0] for call in logger.debug.call_args_list) + + +@pytest.mark.parametrize( + ("estimate_factor", "expected"), + [ + (-0.5, 53), + (0, 53), + (None, 53), + ("invalid", 53), + (float("nan"), 53), + (float("inf"), 53), + (True, 53), + (0.3, ceil(53 * 1.3)), + ], +) +def test_local_prompt_estimate_never_reduces_encoded_content( + help_tool, monkeypatch, estimate_factor, expected +): + tool, _, _ = help_tool + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + get_settings().set("config.model_token_count_estimate_factor", estimate_factor) + monkeypatch.setattr(pr_help_message, "token_counter", Mock(side_effect=RuntimeError("counter unavailable")), + raising=False) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder(), + raising=False) + + assert tool._count_prompt_tokens(PRIMARY, "abc", "de") == expected + + +def test_local_prompt_estimate_uses_raw_estimate_on_overflow(help_tool, monkeypatch): + tool, _, _ = help_tool + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + get_settings().set("config.model_token_count_estimate_factor", 1e308) + monkeypatch.setattr(pr_help_message, "token_counter", Mock(side_effect=RuntimeError("counter unavailable"))) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder()) + + assert tool._count_prompt_tokens(PRIMARY, "abc", "de") == 53 + + +@pytest.mark.parametrize("counter_mode", ["error", "zero", "boolean", "non_integer"]) +async def test_unusable_model_count_uses_local_estimate_before_provider_call( + help_tool, monkeypatch, counter_mode +): + tool, _, _ = help_tool + tool.vars = {"question": "q", "snippets": "A" * 100} + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 70) + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + def unusable_count(**_kwargs): + if counter_mode == "error": + raise RuntimeError("counter unavailable") + return {"zero": 0, "boolean": True, "non_integer": 1.5}[counter_mode] + + monkeypatch.setattr(pr_help_message, "token_counter", unusable_count) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder()) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool._prepare_prediction(PRIMARY) + + tool.ai_handler.chat_completion.assert_awaited_once() + user_prompt = tool.ai_handler.chat_completion.await_args.kwargs["user"] + assert "...(truncated)" in user_prompt + assert len(user_prompt) + 48 <= 70 + + +async def test_local_estimate_overflow_still_tries_providers(help_tool, monkeypatch): + tool, details, _ = help_tool + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + get_settings().set("config.model_token_count_estimate_factor", 1e308) + monkeypatch.setattr(pr_help_message, "token_counter", Mock(side_effect=RuntimeError("counter unavailable"))) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder()) + tool._prepare_prediction = AsyncMock(wraps=tool._prepare_prediction) + tool.ai_handler.chat_completion.side_effect = [RuntimeError("primary unavailable"), (ANSWER, "stop")] + + await tool.run() + + assert [call.args[0] for call in tool._prepare_prediction.await_args_list] == [PRIMARY, BACKUP] + assert attempted_models(tool) == [PRIMARY, BACKUP] + tool.git_provider.publish_comment.assert_called_once() + assert details.model_used == BACKUP + + +async def test_empty_claude_system_prompt_is_normalized_before_fitting(help_tool, monkeypatch): + tool, _, _ = help_tool + model = "anthropic/claude-sonnet-4-5" + tool.vars = {"question": "q", "snippets": "A" * 100} + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 80) + tool.ai_handler.normalize_request_prompts = pr_help_message.LiteLLMAIHandler.normalize_request_prompts + counted_system_prompts = [] + + def count_normalized_prompts(*, messages, **_kwargs): + counted_system_prompts.append(messages[0]["content"]) + return count_message_characters(messages=messages) + + monkeypatch.setattr(pr_help_message, "token_counter", count_normalized_prompts) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool._prepare_prediction(model) + + assert counted_system_prompts + assert set(counted_system_prompts) == {"No system prompt provided"} + call = tool.ai_handler.chat_completion.await_args + assert call.kwargs["system"] == "No system prompt provided" + assert "...(truncated)" in call.kwargs["user"] + + +def test_prompt_fitting_checks_smallest_nonempty_prefix(help_tool, monkeypatch): + tool, _, _ = help_tool + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 10) + + def discontinuous_count(*, messages, **_kwargs): + snippets = messages[1]["content"] + if snippets in ("", pr_help_message.TRUNCATION_MARKER): + return 1 + if snippets == f"A{pr_help_message.TRUNCATION_MARKER}": + return 10 + return 1_000 + + monkeypatch.setattr(pr_help_message, "token_counter", discontinuous_count) + + _, user_prompt = tool._fit_prompts({"question": "q", "snippets": "ABC"}, PRIMARY) + + assert user_prompt == f"A{pr_help_message.TRUNCATION_MARKER}" + + +async def test_marker_only_prompt_is_sent_when_no_document_character_fits(help_tool, monkeypatch): + tool, _, _ = help_tool + tool.vars = {"question": "q", "snippets": "ABC"} + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 10) + + def marker_only_count(*, messages, **_kwargs): + snippets = messages[1]["content"] + if snippets == "": + return 1 + if snippets == pr_help_message.TRUNCATION_MARKER: + return 5 + return 1_000 + + monkeypatch.setattr(pr_help_message, "token_counter", marker_only_count) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool._prepare_prediction(PRIMARY) + + tool.ai_handler.chat_completion.assert_awaited_once() + assert tool.ai_handler.chat_completion.await_args.kwargs["user"] == pr_help_message.TRUNCATION_MARKER + + +async def test_fixed_prompt_overhead_skips_model_call_and_tries_larger_fallback(help_tool, monkeypatch): + tool, details, _ = help_tool + get_settings().set("pr_help_prompts.system", "S" * 300) + get_settings().set("pr_help_prompts.user", "question={{ question }}\nsnippets={{ snippets }}") + get_settings().set("config.max_output_tokens", 20) + limits = {PRIMARY: 250, BACKUP: 2_000} + + monkeypatch.setattr(pr_help_message, "get_max_tokens", + lambda model, ignore_max_model_tokens=False: limits[model]) + monkeypatch.setattr(pr_help_message, "token_counter", count_message_characters, raising=False) + tool._prepare_prediction = AsyncMock(wraps=tool._prepare_prediction) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool.run() + + assert [call.args[0] for call in tool._prepare_prediction.await_args_list] == [PRIMARY, BACKUP] + assert attempted_models(tool) == [BACKUP] + assert details.model_used == BACKUP + + async def test_all_models_fail_without_publishing_no_information(help_tool): tool, details, logger = help_tool tool.ai_handler.chat_completion.side_effect = RuntimeError("provider unavailable") From 9d1735e6e68577f6663630c83d32bffe73492f1f Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Sat, 12 Sep 2026 17:58:31 -0500 Subject: [PATCH 074/238] fix(push_outputs): isolate destination failures (#3307) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- docs/docs/usage-guide/push_outputs.md | 8 +- pr_agent/algo/utils.py | 45 ++++-- tests/unittest/test_push_outputs.py | 211 +++++++++++++++++++++++++- 3 files changed, 243 insertions(+), 21 deletions(-) diff --git a/docs/docs/usage-guide/push_outputs.md b/docs/docs/usage-guide/push_outputs.md index 7000443d21..7fabba0dbc 100644 --- a/docs/docs/usage-guide/push_outputs.md +++ b/docs/docs/usage-guide/push_outputs.md @@ -80,11 +80,11 @@ credential. | `slack` | POSTs `{"text": ...}` to a Slack Incoming Webhook; the text is the markdown, or the payload JSON when the tool produces no markdown. | Local channels (`stdout`, `file`) run before network channels (`webhook`, `slack`), and network -posts never follow redirects, so a failed or redirecting POST cannot lose an already-written file -line or be forwarded to a different host. +posts never follow redirects. Each configured destination is attempted independently, so one failure +does not prevent later destinations from receiving the output. ## Error handling Failures are non-fatal: `push_outputs` never raises, so a sink outage does not break the review -flow. Errors are logged with the exception type only, since request error messages can embed the -(secret-bearing) URL. +flow. Exceptions and non-2xx HTTP responses are logged with the destination and only the exception +type or status code, since request error messages can embed the (secret-bearing) URL. diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 26097e813f..839574d02e 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -1736,29 +1736,46 @@ def push_outputs(message_type: str, payload: dict | None = None, markdown: str | record["markdown"] = markdown if "stdout" in channels: - print(json.dumps(record, ensure_ascii=False)) + try: + print(json.dumps(record, ensure_ascii=False)) + except Exception as e: + get_logger().warning(f"push_outputs: stdout failed: {type(e).__name__}") if "file" in channels: - file_path = cfg.get('file_path', 'pr-agent-outputs/reviews.jsonl') - folder = os.path.dirname(file_path) - if folder: - os.makedirs(folder, exist_ok=True) - with open(file_path, 'a', encoding='utf-8') as fh: - fh.write(json.dumps(record, ensure_ascii=False) + "\n") + try: + file_path = cfg.get('file_path', 'pr-agent-outputs/reviews.jsonl') + folder = os.path.dirname(file_path) + if folder: + os.makedirs(folder, exist_ok=True) + with open(file_path, 'a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + except Exception as e: + get_logger().warning(f"push_outputs: file failed: {type(e).__name__}") # Local channels first, network last, so a failed POST can't lose a file write. # allow_redirects=False: never follow a redirect from a configured sink to another host. if "webhook" in channels: - webhook_url = _push_outputs_sink_url(cfg, 'webhook_url') - if webhook_url: - requests.post(webhook_url, json=record, timeout=5, allow_redirects=False) + try: + webhook_url = _push_outputs_sink_url(cfg, 'webhook_url') + if webhook_url: + response = requests.post(webhook_url, json=record, timeout=5, allow_redirects=False) + if not 200 <= response.status_code < 300: + get_logger().warning(f"push_outputs: webhook failed with status {response.status_code}") + except Exception as e: + get_logger().warning(f"push_outputs: webhook failed: {type(e).__name__}") # Slack Incoming Webhooks accept {"text": ...} directly, no relay service needed. if "slack" in channels: - slack_webhook_url = _push_outputs_sink_url(cfg, 'slack_webhook_url') - if slack_webhook_url: - text = markdown if markdown is not None else json.dumps(payload or {}, ensure_ascii=False) - requests.post(slack_webhook_url, json={"text": text}, timeout=5, allow_redirects=False) + try: + slack_webhook_url = _push_outputs_sink_url(cfg, 'slack_webhook_url') + if slack_webhook_url: + text = markdown if markdown is not None else json.dumps(payload or {}, ensure_ascii=False) + response = requests.post(slack_webhook_url, json={"text": text}, timeout=5, + allow_redirects=False) + if not 200 <= response.status_code < 300: + get_logger().warning(f"push_outputs: slack failed with status {response.status_code}") + except Exception as e: + get_logger().warning(f"push_outputs: slack failed: {type(e).__name__}") except Exception as e: # Log only the exception type: requests errors embed the (secret-bearing) URL in their text. get_logger().warning(f"push_outputs failed: {type(e).__name__}") diff --git a/tests/unittest/test_push_outputs.py b/tests/unittest/test_push_outputs.py index dac6d1103e..bd9d8d9450 100644 --- a/tests/unittest/test_push_outputs.py +++ b/tests/unittest/test_push_outputs.py @@ -1,4 +1,5 @@ import json +from types import SimpleNamespace import pytest @@ -14,6 +15,7 @@ def _reset_push_outputs(): s = get_settings() s.set('PUSH_OUTPUTS.ENABLE', False) s.set('PUSH_OUTPUTS.CHANNELS', []) + s.set('PUSH_OUTPUTS.FILE_PATH', 'pr-agent-outputs/reviews.jsonl') s.set('PUSH_OUTPUTS.WEBHOOK_URL', '') s.set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', '') @@ -65,6 +67,7 @@ def test_slack_channel_posts_text(self, monkeypatch): def fake_post(url, json=None, timeout=None, **kwargs): captured['url'] = url captured['json'] = json + return SimpleNamespace(status_code=200) monkeypatch.setattr(utils.requests, 'post', fake_post) @@ -103,7 +106,7 @@ def get_repo_settings(self): def test_errors_are_non_fatal(self, monkeypatch): get_settings().set('PUSH_OUTPUTS.ENABLE', True) get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook']) - get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'http://example.invalid/hook') + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.invalid/hook') def boom(*args, **kwargs): raise ConnectionError("no network") @@ -128,7 +131,7 @@ def test_non_https_sink_urls_are_ignored(self, monkeypatch, bad_url): posts = [] monkeypatch.setattr(utils.requests, 'post', - lambda url, **kwargs: posts.append(url)) + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -141,8 +144,210 @@ def test_https_sink_url_is_accepted(self, monkeypatch): posts = [] monkeypatch.setattr(utils.requests, 'post', - lambda url, **kwargs: posts.append(url)) + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) push_outputs("review", payload={"a": 1}, markdown="hi") assert posts == ['https://example.test/hook'] + + def test_setup_errors_remain_non_fatal_and_secret_safe(self, monkeypatch): + warnings = [] + + def fail_settings(): + raise RuntimeError("secret setup marker") + + monkeypatch.setattr(utils, 'get_settings', fail_settings) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"payload-secret": 1}, markdown="markdown-secret") + + assert warnings == ["push_outputs failed: RuntimeError"] + assert "secret" not in warnings[0] + + def test_webhook_exception_does_not_skip_slack(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + webhook_url = 'https://example.test/webhook-secret' + slack_url = 'https://example.test/slack-secret' + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', webhook_url) + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + posts = [] + warnings = [] + + def fake_post(url, **kwargs): + posts.append((url, kwargs)) + if url == webhook_url: + raise ConnectionError("transport-secret") + return SimpleNamespace(status_code=200) + + monkeypatch.setattr(utils.requests, 'post', fake_post) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"payload-secret": 1}, markdown="markdown-secret") + + assert [url for url, _ in posts] == [webhook_url, slack_url] + assert posts[0][1]['timeout'] == 5 + assert posts[0][1]['allow_redirects'] is False + assert posts[1][1]['timeout'] == 5 + assert posts[1][1]['allow_redirects'] is False + assert warnings == ["push_outputs: webhook failed: ConnectionError"] + assert not any(secret in warnings[0] for secret in + (webhook_url, slack_url, "transport-secret", "payload-secret", "markdown-secret")) + + @pytest.mark.parametrize("status_code", [302, 500]) + def test_webhook_non_2xx_warns_and_does_not_skip_slack(self, monkeypatch, status_code): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + webhook_url = 'https://example.test/webhook' + slack_url = 'https://example.test/slack' + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', webhook_url) + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + posts = [] + warnings = [] + + def fake_post(url, **kwargs): + posts.append(url) + if url == webhook_url: + return SimpleNamespace(status_code=status_code, text="response-secret") + return SimpleNamespace(status_code=204) + + monkeypatch.setattr(utils.requests, 'post', fake_post) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == [webhook_url, slack_url] + assert warnings == [f"push_outputs: webhook failed with status {status_code}"] + assert "response-secret" not in warnings[0] + + @pytest.mark.parametrize("status_code", [200, 204]) + def test_remote_2xx_responses_are_silent(self, monkeypatch, status_code): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + warnings = [] + monkeypatch.setattr(utils.requests, 'post', + lambda *args, **kwargs: SimpleNamespace(status_code=status_code)) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert warnings == [] + + def test_malformed_webhook_url_does_not_skip_slack(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://[') + slack_url = 'https://example.test/slack' + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + posts = [] + warnings = [] + monkeypatch.setattr(utils.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == [slack_url] + assert warnings == ["push_outputs: webhook failed: ValueError"] + assert "https://[" not in warnings[0] + + def test_stdout_failure_does_not_skip_later_destinations(self, monkeypatch, tmp_path): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['stdout', 'file', 'webhook', 'slack']) + out = tmp_path / 'out.jsonl' + get_settings().set('PUSH_OUTPUTS.FILE_PATH', str(out)) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + posts = [] + warnings = [] + + def fail_print(*args, **kwargs): + raise OSError("stdout-secret") + + monkeypatch.setattr('builtins.print', fail_print) + monkeypatch.setattr(utils.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert out.exists() + assert posts == ['https://example.test/webhook', 'https://example.test/slack'] + assert warnings == ["push_outputs: stdout failed: OSError"] + + def test_file_failure_does_not_skip_remote_destinations(self, monkeypatch, tmp_path): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['file', 'webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.FILE_PATH', str(tmp_path)) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + posts = [] + warnings = [] + monkeypatch.setattr(utils.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == ['https://example.test/webhook', 'https://example.test/slack'] + assert warnings == ["push_outputs: file failed: IsADirectoryError"] + + def test_slack_failure_is_non_fatal_and_secret_safe(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + slack_url = 'https://example.test/slack-secret' + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + warnings = [] + + def fake_post(url, **kwargs): + if url == slack_url: + raise TimeoutError("slack-timeout-secret") + return SimpleNamespace(status_code=200) + + monkeypatch.setattr(utils.requests, 'post', fake_post) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert warnings == ["push_outputs: slack failed: TimeoutError"] + assert slack_url not in warnings[0] + assert "slack-timeout-secret" not in warnings[0] + + def test_slack_non_2xx_warns_without_response_content(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['slack']) + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + warnings = [] + monkeypatch.setattr(utils.requests, 'post', + lambda *args, **kwargs: SimpleNamespace(status_code=429, text="response-secret")) + monkeypatch.setattr(utils, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert warnings == ["push_outputs: slack failed with status 429"] + assert "response-secret" not in warnings[0] + + def test_unknown_and_duplicate_channels_do_not_duplicate_delivery(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['unknown', 'webhook', 'webhook']) + webhook_url = 'https://example.test/webhook' + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', webhook_url) + posts = [] + monkeypatch.setattr(utils.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == [webhook_url] From 796a6fc96bf5d4b4c70c210ef347b49259b58f93 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:00:03 +0300 Subject: [PATCH 075/238] chore(ci): bump the actions-major group with 11 updates (#3297) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-and-test.yaml | 10 +++++----- .github/workflows/codeql.yml | 6 +++--- .github/workflows/docs-ci.yaml | 6 +++--- .github/workflows/e2e_tests.yaml | 6 +++--- .github/workflows/pre-commit.yml | 4 ++-- .github/workflows/publish.yml | 22 +++++++++++----------- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 59a3d46dc7..4f54e6898e 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -14,7 +14,7 @@ jobs: steps: - id: checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -24,11 +24,11 @@ jobs: - id: dockerx name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - id: build name: Build dev docker - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/Dockerfile @@ -53,7 +53,7 @@ jobs: exit 1 fi - name: Upload coverage to Codecov - uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -61,7 +61,7 @@ jobs: # Smoke-start github_app (gunicorn + uvicorn) with no secrets; GET / → 200. - id: build-smoke name: Build github_app docker - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/Dockerfile diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 303d59d7da..dec3c7bf80 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,14 +31,14 @@ jobs: - language: python build-mode: none steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3.35.4 + - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} queries: security-extended,security-and-quality - - uses: github/codeql-action/analyze@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3.35.4 + - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index fb1056c4eb..946daac080 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -12,16 +12,16 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> "$GITHUB_ENV" - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: mkdocs-material-${{ env.cache_id }} path: .cache diff --git a/.github/workflows/e2e_tests.yaml b/.github/workflows/e2e_tests.yaml index 0d33afb903..77255e4b7e 100644 --- a/.github/workflows/e2e_tests.yaml +++ b/.github/workflows/e2e_tests.yaml @@ -11,14 +11,14 @@ jobs: name: PR-Agent E2E GitHub App Test steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - id: build name: Build dev docker - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/Dockerfile diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index a3e0f8500d..0cafc1f026 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -12,8 +12,8 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 # SEE https://github.com/pre-commit/action - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c4f54f0e3..7e6bd0d7b9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -83,11 +83,11 @@ jobs: runs-on: ubuntu-latest environment: release steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.prepare.outputs.sha }} - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' @@ -168,25 +168,25 @@ jobs: suffix: gitlab_lambda rolling: gitlab_lambda steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.prepare.outputs.sha }} - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' - name: Set version in pyproject.toml run: python scripts/set_pyproject_version.py "${{ needs.prepare.outputs.version }}" - - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 with: # amd64 is the runner's native arch; only arm64 needs emulation. platforms: arm64 - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -208,7 +208,7 @@ jobs: echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - id: push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ${{ matrix.dockerfile }} @@ -219,7 +219,7 @@ jobs: cache-from: type=gha,scope=${{ matrix.suffix || 'cli' }} cache-to: type=gha,mode=max,scope=${{ matrix.suffix || 'cli' }} - - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-name: index.docker.io/pragent/pr-agent subject-digest: ${{ steps.push.outputs.digest }} @@ -245,13 +245,13 @@ jobs: # the push rather than rebasing onto a newer commit that wasn't built. # This preserves the invariant that `git checkout vX.Y.Z` resolves to a # commit whose tree was published. - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' From c3a587845c73c5d85e108d0a1bdf66c0d0641cfa Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:48:02 +0545 Subject: [PATCH 076/238] fix(reviewer): normalize aware commit dates in incremental review (#3336) --- pr_agent/tools/pr_reviewer.py | 7 +- .../test_github_incremental_default.py | 100 ++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 4b2ade90df..dbc10c7ba3 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -1223,12 +1223,17 @@ def _can_run_incremental_review(self) -> bool: num_commits_threshold = get_settings().pr_reviewer.minimal_commits_for_incremental_review not_enough_commits = num_new_commits < num_commits_threshold # checking if the commits are not too recent to start the review - recent_commits_threshold = datetime.datetime.now() - datetime.timedelta( + recent_commits_threshold = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta( minutes=get_settings().pr_reviewer.minimal_minutes_for_incremental_review ) last_seen_commit_date = ( self.incremental.last_seen_commit.commit.author.date if self.incremental.last_seen_commit else None ) + # PyGithub returns timezone-aware UTC commit dates; the threshold below is a + # naive datetime. Normalize to naive UTC so the comparison cannot raise + # TypeError, matching how the GitLab and Azure providers emit commit dates. + if last_seen_commit_date is not None and last_seen_commit_date.tzinfo is not None: + last_seen_commit_date = last_seen_commit_date.astimezone(datetime.timezone.utc).replace(tzinfo=None) all_commits_too_recent = ( last_seen_commit_date > recent_commits_threshold if self.incremental.last_seen_commit else False ) diff --git a/tests/unittest/test_github_incremental_default.py b/tests/unittest/test_github_incremental_default.py index fe707356bc..c3519c3dd4 100644 --- a/tests/unittest/test_github_incremental_default.py +++ b/tests/unittest/test_github_incremental_default.py @@ -1,4 +1,6 @@ """The default `IncrementalPR` is per call, not one object shared since import.""" +import datetime +from types import SimpleNamespace from unittest.mock import MagicMock, patch from pr_agent.git_providers.git_provider import IncrementalPR @@ -55,3 +57,101 @@ def test_an_incremental_passed_in_is_the_one_used(): provider.get_incremental_commits(given) assert provider.incremental is given + + +def test_can_run_incremental_review_handles_aware_commit_dates(): + """Regression: PyGithub 2.x returns timezone-aware commit timestamps, + but _can_run_incremental_review compared them against a naive + datetime.now(), raising TypeError on every second run.""" + from pr_agent.tools.pr_reviewer import PRReviewer + + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_auto = False + reviewer.git_provider = MagicMock(spec=["get_incremental_commits"]) + reviewer.pr_url = "https://github.com/test/repo/pull/1" + + aware_date = datetime.datetime(2026, 9, 10, 12, 0, tzinfo=datetime.timezone.utc) + commit = SimpleNamespace(commit=SimpleNamespace(author=SimpleNamespace(date=aware_date))) + + reviewer.incremental = IncrementalPR(True) + reviewer.incremental.commits_range = [commit] + reviewer.incremental.last_seen_commit = commit + + with patch("pr_agent.tools.pr_reviewer.get_settings") as mock_settings: + mock_settings.return_value.git_provider = "github" + mock_settings.return_value.pr_reviewer.minimal_commits_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.minimal_minutes_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.require_all_thresholds_for_incremental_review = False + # Must not raise TypeError + result = reviewer._can_run_incremental_review() + + assert result in (True, False) # either outcome is valid; crash is the bug + + +def _frozen_now(fixed_aware_utc): + class _FrozenDatetime: + timezone = datetime.timezone + timedelta = datetime.timedelta + + class datetime: + @staticmethod + def now(*args, **kwargs): + return fixed_aware_utc.astimezone(datetime.timezone.utc) + + return patch("pr_agent.tools.pr_reviewer.datetime", new=_FrozenDatetime) + + +def test_incremental_review_runs_when_commit_is_older_than_utc_threshold(): + """The threshold is naive UTC, so a host in a non-UTC zone must not shift + the minimum-age decision.""" + from pr_agent.tools.pr_reviewer import PRReviewer + + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_auto = False + reviewer.git_provider = MagicMock(spec=["get_incremental_commits"]) + reviewer.pr_url = "https://github.com/test/repo/pull/1" + + commit = SimpleNamespace( + commit=SimpleNamespace(author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 12, 0, tzinfo=datetime.timezone.utc))) + ) + reviewer.incremental = IncrementalPR(True) + reviewer.incremental.commits_range = [commit] + reviewer.incremental.last_seen_commit = commit + + with ( + _frozen_now(datetime.datetime(2026, 9, 10, 13, 0, tzinfo=datetime.timezone.utc)), + patch("pr_agent.tools.pr_reviewer.get_settings") as mock_settings, + ): + mock_settings.return_value.git_provider = "github" + mock_settings.return_value.pr_reviewer.minimal_commits_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.minimal_minutes_for_incremental_review = 30 + mock_settings.return_value.pr_reviewer.require_all_thresholds_for_incremental_review = False + + assert reviewer._can_run_incremental_review() is True + + +def test_incremental_review_skipped_when_commit_is_newer_than_utc_threshold(): + from pr_agent.tools.pr_reviewer import PRReviewer + + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_auto = False + reviewer.git_provider = MagicMock(spec=["get_incremental_commits"]) + reviewer.pr_url = "https://github.com/test/repo/pull/1" + + commit = SimpleNamespace( + commit=SimpleNamespace(author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 13, 15, tzinfo=datetime.timezone.utc))) + ) + reviewer.incremental = IncrementalPR(True) + reviewer.incremental.commits_range = [commit] + reviewer.incremental.last_seen_commit = commit + + with ( + _frozen_now(datetime.datetime(2026, 9, 10, 13, 0, tzinfo=datetime.timezone.utc)), + patch("pr_agent.tools.pr_reviewer.get_settings") as mock_settings, + ): + mock_settings.return_value.git_provider = "github" + mock_settings.return_value.pr_reviewer.minimal_commits_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.minimal_minutes_for_incremental_review = 30 + mock_settings.return_value.pr_reviewer.require_all_thresholds_for_incremental_review = True + + assert reviewer._can_run_incremental_review() is False From 92c0aa69ecfc5e8357fb60dd61bf958103cb5c2a Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:48:52 +0545 Subject: [PATCH 077/238] fix(azure): return language names from get_languages (#3334) --- .../git_providers/azuredevops_provider.py | 51 +++++---- tests/unittest/test_azure_devops_provider.py | 103 ++++++++++++++++++ 2 files changed, 135 insertions(+), 19 deletions(-) diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index 35906ba4cf..6ab1f45a8c 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -2,8 +2,8 @@ import datetime as _dt import json -import os import re +from collections import Counter from types import SimpleNamespace from typing import Optional, Tuple from urllib.parse import quote, unquote, urlparse @@ -19,7 +19,7 @@ get_inline_comment_store, has_marker, ) -from ..algo.language_handler import is_valid_file +from ..algo.language_handler import build_language_file_matcher, is_valid_file from ..algo.utils import ( PRCodeSuggestionsIdentity, PRDescriptionHeader, @@ -1160,7 +1160,24 @@ def get_title(self): return self.pr.title def get_languages(self): - languages = [] + # Return {language name: percentage}, like the other providers. Keys are + # language NAMES (e.g. "Python"), not raw extensions: the consumer + # sort_files_by_main_languages() maps each name back to its extensions, so + # returning extensions ("py") silently drops every file into the "Other" + # bucket and defeats the prioritisation. Use the shared configured + # filename matcher so all providers apply the same rules. Percentages are + # computed over the repository's blob inventory (not the PR change set), + # so transient files or untouched files in the PR cannot skew the ranking. + lang_map = get_settings().get("language_extension_map_org", {}) or {} + get_language = build_language_file_matcher(lang_map) + + # Azure may omit merge metadata for a PR; without a target commit there is no + # reliable revision to enumerate, so mirror the empty-map fallback used by the + # other providers for unrecognized repositories. + target_commit = getattr(self.pr, "last_merge_target_commit", None) + if target_commit is None or not getattr(target_commit, "commit_id", None): + return {} + files = self.azure_devops_client.get_items( project=self.workspace_slug, repository_id=self.repo_slug, @@ -1168,25 +1185,21 @@ def get_languages(self): include_content_metadata=True, include_links=False, download=False, + version_descriptor=GitVersionDescriptor( + version=target_commit.commit_id, version_type="commit" + ), ) - for f in files: - if f.git_object_type == "blob": - file_name, file_extension = os.path.splitext(f.path) - languages.append(file_extension[1:]) - - extension_counts = {} - for ext in languages: - if ext != "": - extension_counts[ext] = extension_counts.get(ext, 0) + 1 - total_extensions = sum(extension_counts.values()) - - extension_percentages = { - ext: (count / total_extensions) * 100 - for ext, count in extension_counts.items() - } + lang_count = Counter() + for f in files: + if f.git_object_type != "blob" or not f.path: + continue + language = get_language(f.path) + if language: + lang_count[language] += 1 - return extension_percentages + total = sum(lang_count.values()) or 1 + return {lang: count / total * 100 for lang, count in lang_count.items()} def get_pr_branch(self): pr_info = self.azure_devops_client.get_pull_request_by_id( diff --git a/tests/unittest/test_azure_devops_provider.py b/tests/unittest/test_azure_devops_provider.py index c856f42785..e1cbc6563c 100644 --- a/tests/unittest/test_azure_devops_provider.py +++ b/tests/unittest/test_azure_devops_provider.py @@ -41,6 +41,109 @@ def test_azure_resolve_comment_thread_closes_thread(): provider.set_thread_status.assert_called_once_with(42, "closed") +def _language_provider(items): + provider = AzureDevopsProvider.__new__(AzureDevopsProvider) + provider.workspace_slug = "proj" + provider.repo_slug = "repo" + provider.pr = SimpleNamespace( + last_merge_target_commit=SimpleNamespace(commit_id="base-sha"), + last_merge_commit=SimpleNamespace(commit_id="head-sha"), + ) + provider.azure_devops_client = MagicMock() + provider.azure_devops_client.get_items.return_value = items + return provider + + +def test_get_languages_returns_language_names(): + # get_languages() must key on language NAMES (e.g. "Python"), not raw + # extensions ("py"): sort_files_by_main_languages() maps names back to + # extensions, so extension keys would drop every file into "Other". + provider = _language_provider([ + SimpleNamespace(git_object_type="blob", path="a.py"), + SimpleNamespace(git_object_type="blob", path="b.py"), + SimpleNamespace(git_object_type="blob", path="c.py"), + SimpleNamespace(git_object_type="blob", path="d.js"), + SimpleNamespace(git_object_type="blob", path="weird.zzz"), + ]) + + languages = provider.get_languages() + + # 3 Python + 1 JavaScript known; .zzz is unknown and excluded from the total. + assert languages == {"Python": 75.0, "JavaScript": 25.0} + + +def test_get_languages_queries_target_commit_inventory(): + provider = _language_provider( + [SimpleNamespace(git_object_type="blob", path="a.py")] + ) + + provider.get_languages() + + call_kwargs = provider.azure_devops_client.get_items.call_args.kwargs + assert call_kwargs["version_descriptor"].version == "base-sha" + assert call_kwargs["version_descriptor"].version_type == "commit" + + +def test_get_languages_returns_empty_map_when_target_commit_missing(): + provider = _language_provider( + [SimpleNamespace(git_object_type="blob", path="a.py")] + ) + provider.pr = SimpleNamespace(last_merge_target_commit=None) + + assert provider.get_languages() == {} + provider.azure_devops_client.get_items.assert_not_called() + + +def test_get_languages_returns_empty_map_when_target_commit_id_missing(): + provider = _language_provider( + [SimpleNamespace(git_object_type="blob", path="a.py")] + ) + provider.pr = SimpleNamespace(last_merge_target_commit=SimpleNamespace(commit_id=None)) + + assert provider.get_languages() == {} + provider.azure_devops_client.get_items.assert_not_called() + + +def test_get_languages_returns_empty_map_when_nothing_matches(): + provider = _language_provider([ + SimpleNamespace(git_object_type="blob", path="weird.zzz"), + SimpleNamespace(git_object_type="blob", path=""), + ]) + + assert provider.get_languages() == {} + + +def test_get_languages_maps_full_paths_and_multipart_extensions(): + # The matcher must classify by the basename and honor multipart extensions. + provider = _language_provider([ + SimpleNamespace(git_object_type="blob", path="src/foo.py"), + SimpleNamespace(git_object_type="blob", path="lib/bar.py"), + SimpleNamespace(git_object_type="blob", path="doc/README.md"), + SimpleNamespace(git_object_type="blob", path="notes.txt"), + SimpleNamespace(git_object_type="blob", path="tpl/file.test.ts"), + ]) + + languages = provider.get_languages() + + assert languages == {"Python": 40.0, "Markdown": 20.0, "Text": 20.0, "TypeScript": 20.0} + + +def test_get_languages_ignores_non_blob_items_and_other_languages(): + # Percentages come from the repository blob inventory, not the PR change set: + # non-blob entries (e.g. folders) must be skipped, and off-language files + # must not skew the ranking. + provider = _language_provider([ + SimpleNamespace(git_object_type="Folder", path="src"), + SimpleNamespace(git_object_type="blob", path="src/app.py"), + SimpleNamespace(git_object_type="blob", path="src/main.py"), + SimpleNamespace(git_object_type="blob", path="render.bin"), + ]) + + languages = provider.get_languages() + + assert languages == {"Python": 100.0} + + class TestAzureDevopsProviderRepoContext: def test_get_repo_file_content_reads_from_target_commit(self): # Repo-context files must be read from the PR target (base) commit, matching From 05a007e88862d5766849d36d1313e550abec5e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=9D=80=EC=9A=B0?= <80899085+be-student@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:32:04 +0900 Subject: [PATCH 078/238] fix(review): preserve resolved finding markdown (#3092) --- docs/docs/tools/review.md | 2 +- .../test_pr_reviewer_finding_state.py | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/docs/tools/review.md b/docs/docs/tools/review.md index 9c64269d60..7ff51f00f4 100644 --- a/docs/docs/tools/review.md +++ b/docs/docs/tools/review.md @@ -83,7 +83,7 @@ for the authoritative default values. - + diff --git a/tests/unittest/test_pr_reviewer_finding_state.py b/tests/unittest/test_pr_reviewer_finding_state.py index 5d67ecc2de..34a211bba7 100644 --- a/tests/unittest/test_pr_reviewer_finding_state.py +++ b/tests/unittest/test_pr_reviewer_finding_state.py @@ -5,6 +5,8 @@ import pytest from pr_agent.algo.review_finding_state import ( + _render_resolved_section, + normalize_finding, parse_review_state, reconcile_review_findings, serialize_review_state, @@ -61,6 +63,54 @@ def _settings(monkeypatch): return settings +def test_normalize_finding_preserves_markdown_while_fingerprint_ignores_rewrapping(): + body = "The loop never ends:\n```text\nwhile attempts < 3:\n attempts += 1\n```" + rewrapped = "The loop never ends: ```text while attempts < 3: attempts += 1 ```" + + normalized = normalize_finding(_finding(body)) + rewrapped_normalized = normalize_finding(_finding(rewrapped)) + + assert normalized["body"] == body + assert normalized["finding_id"] == rewrapped_normalized["finding_id"] + + +def test_resolved_section_preserves_finding_markdown_structure(): + issue = { + "relevant_file": "src/app.py", + "issue_header": "Possible Bug", + "issue_content": "The loop never ends:\n```suggestion\nwhile attempts < 3:\n attempts += 1\n```", + "start_line": 3, + "end_line": 4, + } + finding = PRReviewer._review_finding_from_issue(issue) + active = reconcile_review_findings( + None, + [finding], + allow_resolution=False, + head_sha="head-1", + timestamp="2026-01-01T00:00:00Z", + ).state + resolved = reconcile_review_findings( + active, + [], + allow_resolution=True, + head_sha="head-2", + timestamp="2026-01-01T00:01:00Z", + ).state + + rendered = _render_resolved_section(resolved) + + assert ( + "### src/app.py:3-4\n\n" + "**Possible Issue**\n\n" + "The loop never ends:\n" + "```text\n" + "while attempts < 3:\n" + " attempts += 1\n" + "```" + ) in rendered + + def test_review_finding_state_is_disabled_without_a_provider(monkeypatch): _settings(monkeypatch) From 74210c45b9dc59335ea62787dc63babdbd0a5142 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:17:20 +0545 Subject: [PATCH 079/238] fix: remove dead configuration keys with no reader (#3279) Co-authored-by: Utsab --- .../usage-guide/configuration_reference.md | 93 +------- pr_agent/settings/configuration.toml | 65 ------ pr_agent/tools/pr_help_message.py | 2 +- tests/unittest/test_config_dead_keys.py | 219 ++++++++++++++++++ tests/unittest/test_config_reference.py | 2 +- 5 files changed, 222 insertions(+), 159 deletions(-) create mode 100644 tests/unittest/test_config_dead_keys.py diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index a456b52b25..782a0c99c4 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -180,7 +180,6 @@ to-do list. | `publish_labels` | false | | | `add_original_user_description` | true | | | `generate_ai_title` | false | | -| `use_bullet_points` | true | | | `extra_instructions` | "" | | | `enable_pr_type` | true | | | `enable_pr_description` | true | adds a section with an AI-generated summary of the PR changes | @@ -253,12 +252,10 @@ to-do list. | Key | Default | Description | | --- | --- | --- | -| `auto_extended_mode` | true | | | `num_code_suggestions_per_chunk` | 3 | | | `max_suggestions_per_file` | 0 | Maximum suggestions retained per file after all chunks are merged; 0 disables the cap. | | `max_number_of_calls` | 3 | | | `parallel_calls` | true | | -| `final_clip_factor` | 0.8 | | | `decouple_hunks` | false | | **self-review checkbox** @@ -270,25 +267,12 @@ to-do list. | `fold_suggestions_on_self_review` | true | if true, the code suggestions will be folded after the author clicks on the self-review checkbox | -## `[pr_custom_prompt]` — /custom_prompt - -| Key | Default | Description | -| --- | --- | --- | -| `prompt` | """The code suggestions should focus only on the following: ...""" | | -| `suggestions_score_threshold` | 0 | | -| `num_code_suggestions_per_chunk` | 3 | | -| `self_reflect_on_custom_suggestions` | true | | -| `enable_help_text` | false | | - - ## `[pr_add_docs]` — /add_docs | Key | Default | Description | | --- | --- | --- | | `extra_instructions` | "" | | | `docs_style` | "Sphinx" | "Google Style with Args, Returns, Attributes...etc", "Numpy Style", "Sphinx Style", "PEP257", "reStructuredText" | -| `file` | "" | in case there are several components with the same name, you can specify the relevant file | -| `class_name` | "" | in case there are several methods with the same name in the same file, you can specify the relevant class name | ## `[pr_update_changelog]` — /update_changelog @@ -301,44 +285,6 @@ to-do list. | `skip_ci_on_push` | true | | -## `[pr_analyze]` — /analyze - -| Key | Default | Description | -| --- | --- | --- | -| `enable_help_text` | true | | - - -## `[pr_test]` — /test - -| Key | Default | Description | -| --- | --- | --- | -| `extra_instructions` | "" | | -| `testing_framework` | "" | specify the testing framework you want to use | -| `num_tests` | 3 | number of tests to generate. max 5. | -| `avoid_mocks` | true | if true, the generated tests will prefer to use real objects instead of mocks | -| `file` | "" | in case there are several components with the same name, you can specify the relevant file | -| `class_name` | "" | in case there are several methods with the same name in the same file, you can specify the relevant class name | -| `enable_help_text` | false | | - - -## `[pr_improve_component]` — /improve_component - -| Key | Default | Description | -| --- | --- | --- | -| `num_code_suggestions` | 4 | | -| `extra_instructions` | "" | | -| `file` | "" | in case there are several components with the same name, you can specify the relevant file | -| `class_name` | "" | in case there are several methods with the same name in the same file, you can specify the relevant class name | - - -## `[pr_help]` — /help - -| Key | Default | Description | -| --- | --- | --- | -| `force_local_db` | false | | -| `num_retrieved_snippets` | 5 | | - - ## `[pr_config]` — /config _This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ @@ -366,9 +312,7 @@ _This section only documents commented-out examples; see the [TOML source](https | `api_retries` | 0 | max retries per request with backoff; 0 = no retries (1.59 behaviour) | | `polling_request_timeout` | 10 | total seconds for comment-history fallback; positive values capped at 60 | | `base_url` | "https://api.github.com" | | -| `publish_inline_comments_fallback_with_verification` | true | | | `try_fix_invalid_inline_comments` | true | | -| `app_name` | "pr-agent" | | | `ignore_bot_pr` | true | | | `publish_as_check_run` | false | when true, publish review/description/improve output as GitHub Checks instead of PR comments | @@ -381,8 +325,7 @@ _This section only documents commented-out examples; see the [TOML source](https | Key | Default | Description | | --- | --- | --- | -| `bot_user` | "github-actions[bot]" | these toggles allows running the github app from custom deployments | -| `override_deployment_type` | true | | +| `override_deployment_type` | true | these toggles allows running the github app from custom deployments | **settings for "pull_request" event** | Key | Default | Description | @@ -395,7 +338,6 @@ _This section only documents commented-out examples; see the [TOML source](https | `handle_push_trigger` | false | settings for "pull_request" event with "synchronize" action - used to detect and handle push triggers for new commits | | `push_trigger_ignore_bot_commits` | true | | | `push_trigger_ignore_merge_commits` | true | | -| `push_trigger_wait_for_initial_review` | true | | | `push_trigger_pending_tasks_backlog` | true | | | `push_trigger_pending_tasks_ttl` | 300 | | | `push_commands` | ["/describe", "/review"] | | @@ -522,18 +464,6 @@ _This section only documents commented-out examples; see the [TOML source](https | `vectordb` | "lancedb" | options: "pinecone", "lancedb", "qdrant" | -## `[pr_find_similar_component]` - -| Key | Default | Description | -| --- | --- | --- | -| `class_name` | "" | | -| `file` | "" | | -| `search_from_org` | false | | -| `allow_fallback_less_words` | true | | -| `number_of_keywords` | 5 | | -| `number_of_results` | 5 | | - - ## `[pinecone]` | Key | Default | Description | @@ -562,27 +492,6 @@ _This section only documents commented-out examples; see the [TOML source](https | `max_skills_tokens` | 8000 | token budget for the combined skills_context block | -## `[best_practices]` - -| Key | Default | Description | -| --- | --- | --- | -| `content` | "" | | -| `organization_name` | "" | | -| `max_lines_allowed` | 800 | | -| `enable_global_best_practices` | false | | - - -## `[auto_best_practices]` - -| Key | Default | Description | -| --- | --- | --- | -| `enable_auto_best_practices` | true | public - general flag to disable all auto best practices usage | -| `utilize_auto_best_practices` | true | public - disable usage of auto best practices in the 'improve' tool | -| `extra_instructions` | "" | public - extra instructions to the auto best practices generation prompt | -| `content` | "" | | -| `max_patterns` | 5 | max number of patterns to be detected | - - ## `[artifacts]` | Key | Default | Description | diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 79bc5edccf..4e6cd8ebe8 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -160,7 +160,6 @@ max_number_of_calls=3 # maximum number of chunk review calls, used only when ena publish_labels=false add_original_user_description=true generate_ai_title=false -use_bullet_points=true extra_instructions = "" enable_pr_type=true enable_pr_description=true # adds a section with an AI-generated summary of the PR changes @@ -216,14 +215,12 @@ new_score_mechanism=true new_score_mechanism_th_high=9 new_score_mechanism_th_medium=7 # params for '/improve --extended' mode -auto_extended_mode=true num_code_suggestions_per_chunk=3 # Maximum suggestions retained per file after all chunks are merged; 0 disables the cap. max_suggestions_per_file = 0 max_number_of_calls = 3 parallel_calls = true -final_clip_factor = 0.8 decouple_hunks = false # self-review checkbox demand_code_suggestions_self_review=false # add a checkbox for the author to self-review the code suggestions @@ -231,24 +228,9 @@ code_suggestions_self_review_text= "**Author self-review**: I have reviewed the approve_pr_on_self_review=false # if true, the PR will be auto-approved after the author clicks on the self-review checkbox fold_suggestions_on_self_review=true # if true, the code suggestions will be folded after the author clicks on the self-review checkbox -[pr_custom_prompt] # /custom_prompt # -prompt = """\ -The code suggestions should focus only on the following: -- ... -- ... -... -""" -suggestions_score_threshold=0 -num_code_suggestions_per_chunk=3 -self_reflect_on_custom_suggestions=true -enable_help_text=false - - [pr_add_docs] # /add_docs # extra_instructions = "" docs_style = "Sphinx" # "Google Style with Args, Returns, Attributes...etc", "Numpy Style", "Sphinx Style", "PEP257", "reStructuredText" -file = "" # in case there are several components with the same name, you can specify the relevant file -class_name = "" # in case there are several methods with the same name in the same file, you can specify the relevant class name [pr_update_changelog] # /update_changelog # push_changelog_changes=false @@ -256,28 +238,6 @@ extra_instructions = "" add_pr_link=true skip_ci_on_push=true -[pr_analyze] # /analyze # -enable_help_text=true - -[pr_test] # /test # -extra_instructions = "" -testing_framework = "" # specify the testing framework you want to use -num_tests=3 # number of tests to generate. max 5. -avoid_mocks=true # if true, the generated tests will prefer to use real objects instead of mocks -file = "" # in case there are several components with the same name, you can specify the relevant file -class_name = "" # in case there are several methods with the same name in the same file, you can specify the relevant class name -enable_help_text=false - -[pr_improve_component] # /improve_component # -num_code_suggestions=4 -extra_instructions = "" -file = "" # in case there are several components with the same name, you can specify the relevant file -class_name = "" # in case there are several methods with the same name in the same file, you can specify the relevant class name - -[pr_help] # /help # -force_local_db=false -num_retrieved_snippets=5 - [pr_config] # /config # [pr_help_docs] @@ -301,9 +261,7 @@ seconds_between_writes = 0 # seconds between write calls; 0 = no pacing (1.59 be api_retries = 0 # max retries per request with backoff; 0 = no retries (1.59 behaviour) polling_request_timeout = 10 # total seconds for comment-history fallback; positive values capped at 60 base_url = "https://api.github.com" -publish_inline_comments_fallback_with_verification = true try_fix_invalid_inline_comments = true -app_name = "pr-agent" ignore_bot_pr = true publish_as_check_run = false # when true, publish review/description/improve output as GitHub Checks instead of PR comments @@ -322,7 +280,6 @@ publish_as_check_run = false # when true, publish review/description/improve out [github_app] # these toggles allows running the github app from custom deployments -bot_user = "github-actions[bot]" override_deployment_type = true # settings for "pull_request" event handle_pr_actions = ['opened', 'reopened', 'ready_for_review'] @@ -335,7 +292,6 @@ review_commands = [] handle_push_trigger = false push_trigger_ignore_bot_commits = true push_trigger_ignore_merge_commits = true -push_trigger_wait_for_initial_review = true push_trigger_pending_tasks_backlog = true push_trigger_pending_tasks_ttl = 300 push_commands = [ @@ -498,14 +454,6 @@ force_update_dataset = false max_issues_to_scan = 500 vectordb = "lancedb" # options: "pinecone", "lancedb", "qdrant" -[pr_find_similar_component] -class_name = "" -file = "" -search_from_org = false -allow_fallback_less_words = true -number_of_keywords = 5 -number_of_results = 5 - [pinecone] # fill and place in .secrets.toml #api_key = ... @@ -536,19 +484,6 @@ enabled = false paths = [] # directories to scan recursively for "*/SKILL.md"; supports ~ and $VAR max_skills_tokens = 8000 # token budget for the combined skills_context block -[best_practices] -content = "" -organization_name = "" -max_lines_allowed = 800 -enable_global_best_practices = false - -[auto_best_practices] -enable_auto_best_practices = true # public - general flag to disable all auto best practices usage -utilize_auto_best_practices = true # public - disable usage of auto best practices in the 'improve' tool -extra_instructions = "" # public - extra instructions to the auto best practices generation prompt -content = "" -max_patterns = 5 # max number of patterns to be detected - [artifacts] # Enable artifact injection into tool prompts (off by default; auto-enabled when artifact_path input is set) enable = false diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 5fc78d999a..70c1f7c881 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -324,7 +324,7 @@ async def run(self): return get_logger().info('Getting PR Help Message...') - relevant_configs = {'pr_help': dict(get_settings().pr_help), + relevant_configs = {'pr_help': dict(get_settings().get("pr_help", {})), 'config': dict(get_settings().config)} get_logger().debug("Relevant configs", artifacts=relevant_configs) pr_comment = "## PR Agent Walkthrough 🤖\n\n" diff --git a/tests/unittest/test_config_dead_keys.py b/tests/unittest/test_config_dead_keys.py new file mode 100644 index 0000000000..ba37c7f67c --- /dev/null +++ b/tests/unittest/test_config_dead_keys.py @@ -0,0 +1,219 @@ +"""Regression guard for dead configuration keys. + +Every key in pr_agent/settings/configuration.toml must be read somewhere in +pr_agent/ source code. Static detection is deliberately conservative: keys are +matched through the common read patterns below, and keys that are genuinely +used through variable indirection (e.g. a settings section assigned to a local +variable and then indexed) are recorded in the allowlist with a note pointing +at the reading site. + +A whole-section read (settings.get("SECTION", ...)) does not count as reading +its keys: each key must be matched through an attribute chain, a quoted +dotted path, or a section-level .get("KEY") / getattr() call, or it must be +allowlisted. + +Adding a key without a reader - or leaving a config-only key in the TOML - +fails this test, so the "dead key ledger" cannot silently regrow. +""" + +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CONFIG_TOML = ROOT / "pr_agent/settings/configuration.toml" +PR_AGENT_SOURCE = ROOT / "pr_agent" + +# Keys read through a settings section stored in a local variable, so the +# static dotted-path scan cannot see them. Each entry documents the reader. +_ALLOWLIST = { + # [artifacts] read as artifacts_settings = get_settings().get("ARTIFACTS", {}) + ( + "artifacts", + "max_artifact_size", + ): "artifacts_settings.get('max_artifact_size', 50000) in pr_agent/algo/artifacts.py", + ( + "artifacts", + "artifact_label", + ): "artifacts_settings.get('artifact_label', '') in pr_agent/algo/artifacts.py", + # [config] helpers that re-index the section from a variable or expression + ( + "config", + "reaction_on_failure", + ): "get_reaction_setting('reaction_on_success' if succeeded else 'reaction_on_failure') " + "reads get_settings().config.get(name) in pr_agent/git_providers/git_provider.py", + ( + "config", + "output_relevant_configurations", + ): "get_settings().get('config', {}).get('output_relevant_configurations', False) in " + "pr_agent/tools/pr_description.py", + ( + "config", + "enable_claude_adaptive_thinking", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "config", + "enable_claude_extended_thinking", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "config", + "extended_thinking_budget_tokens", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "config", + "extended_thinking_max_output_tokens", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + # [gerrit] read as gerrit_settings = get_settings().get("gerrit", {}) + ( + "gerrit", + "webhook_username", + ): "gerrit_settings.get('webhook_username', None) in pr_agent/servers/gerrit_server.py", + ( + "gerrit", + "webhook_password", + ): "gerrit_settings.get('webhook_password', None) in pr_agent/servers/gerrit_server.py", + ( + "github", + "api_retries", + ): "github_config = get_settings().github then github_config.get('api_retries') in " + "pr_agent/git_providers/github_provider.py", + ( + "github", + "seconds_between_requests", + ): "github_config.get('seconds_between_requests') in pr_agent/git_providers/github_provider.py", + ( + "github", + "seconds_between_writes", + ): "github_config.get('seconds_between_writes') in pr_agent/git_providers/github_provider.py", + # [openrouter] read as openrouter_settings = settings.get("openrouter", {}) or {} + ( + "openrouter", + "provider_only", + ): "openrouter_settings.get('provider_only', []) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "provider_order", + ): "openrouter_settings.get('provider_order', []) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "allow_fallbacks", + ): "openrouter_settings.get('allow_fallbacks', True) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "reasoning_effort", + ): "openrouter_settings.get('reasoning_effort', '') in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "reasoning_max_tokens", + ): "openrouter_settings.get('reasoning_max_tokens', 0) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "max_tokens", + ): "openrouter_settings.get('max_tokens', 0) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "pr_description", + "pr_diagram_direction", + ): "description_settings = get_settings().pr_description then " + "description_settings.pr_diagram_direction in pr_agent/tools/pr_description.py", + ( + "pr_description", + "pr_diagram_direction_threshold", + ): "description_settings.pr_diagram_direction_threshold in pr_agent/tools/pr_description.py", + # [push_outputs] read as cfg = get_settings().get('push_outputs', {}) or {} + ( + "push_outputs", + "enable", + ): "cfg.get('enable', False) in pr_agent/algo/utils.py", + ( + "push_outputs", + "channels", + ): "cfg.get('channels', []) in pr_agent/algo/utils.py", + ( + "push_outputs", + "file_path", + ): "cfg.get('file_path', 'pr-agent-outputs/reviews.jsonl') in pr_agent/algo/utils.py", + ( + "push_outputs", + "webhook_url", + ): "_push_outputs_sink_url(cfg, 'webhook_url') in pr_agent/algo/utils.py", + ( + "push_outputs", + "slack_webhook_url", + ): "_push_outputs_sink_url(cfg, 'slack_webhook_url') in pr_agent/algo/utils.py", +} + + +def _read_sources() -> str: + return "\n".join( + p.read_text(encoding="utf-8") for p in sorted(PR_AGENT_SOURCE.rglob("*.py")) + ).lower() + + +def _key_is_read(section: str, key: str, sources: str) -> bool: + section_name = re.escape(section) + key_name = re.escape(key) + settings_prefix = ( + r"(?:get_settings\s*\(\s*\)|global_settings|self\s*\.\s*settings|settings)" + ) + # 1. attribute chain: settings.
    . + if re.search( + settings_prefix + r"\s*\.\s*" + section_name + r"\s*\.\s*" + key_name + r"\b", + sources, + ): + return True + # 2. quoted dotted paths: "SECTION.KEY" or "section.key" + for dotted in (f"{section.upper()}.{key.upper()}", f"{section}.{key}"): + if re.search(re.escape(f"\"{dotted}\"") + r"|" + re.escape(f"'{dotted}'"), sources): + return True + # 3. settings.
    .get("KEY") + if re.search( + settings_prefix + r"\s*\.\s*" + section_name + r"\s*\.\s*get\s*\(\s*['\"]" + + key_name + r"['\"]", + sources, + ): + return True + # 4. getattr(settings.
    , "KEY", ...) + if re.search( + r"getattr\s*\(\s*" + settings_prefix + r"\s*\.\s*" + section_name + + r"\s*,\s*['\"]" + key_name + r"['\"]", + sources, + ): + return True + # 5. helper indirection for the [config] section: _read_bool_setting("key") + # and get_reaction_setting("key") both read get_settings().config.get(key). + if section == "config": + for helper in ("_read_bool_setting", "get_reaction_setting"): + if re.search( + r"\b" + re.escape(helper) + r"\s*\(\s*['\"]" + key_name + r"['\"]", + sources, + ): + return True + return False + + +def test_every_config_key_has_a_reader_or_is_allowlisted(): + sources = _read_sources() + with open(CONFIG_TOML, "rb") as f: + settings = tomllib.load(f) + + unread = { + (section, key) + for section, entries in settings.items() + if not section.startswith("_") + for key in entries + if not _key_is_read(section, key, sources) + } + allowlisted = set(_ALLOWLIST) + + assert unread <= allowlisted, ( + "configuration.toml keys have no detected reader; remove them or allowlist them: " + f"{sorted(unread - allowlisted)}" + ) + assert ( + allowlisted - unread == set() + ), "allowlist has stale entries (those keys are now detected as read or no longer present): " \ + f"{sorted(allowlisted - unread)}" diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index d58114b717..5bb7c1d93b 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 298 + assert sum(keys.values()) == 255 assert { "model", From 03d532be7749125447353090b074e279718ba6b0 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:05:14 +0545 Subject: [PATCH 080/238] fix(algo): skip non-unified hunk headers when anchoring lines (#3345) --- pr_agent/algo/utils.py | 38 ++++++++++++-- ...nd_line_number_of_relevant_line_in_file.py | 51 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 839574d02e..44b7d9b359 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -1610,12 +1610,24 @@ def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo], delta = 0 start1, size1, start2, size2 = 0, 0, 0, 0 if absolute_position != -1: # matching absolute to relative + skip_hunk = False for i, line in enumerate(patch_lines): # new hunk if line.startswith('@@'): delta = 0 match = re_hunk_header.match(line) - section_header, size1, size2, start1, start2 = extract_hunk_headers(match) + if match: + skip_hunk = False + section_header, size1, size2, start1, start2 = extract_hunk_headers(match) + else: + # combined/merge hunk headers (e.g. '@@@ ... @@@') cannot be anchored, + # so skip the whole hunk instead of crashing + get_logger().warning("Skipping a line that starts with '@@' but is not a " + "unified hunk header", artifact={"line": line}) + skip_hunk = True + continue + elif skip_hunk: + continue elif not line.startswith('-'): delta += 1 @@ -1640,11 +1652,21 @@ def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo], def scan_patch_lines(is_match): scan_delta = 0 scan_start2 = 0 + skip_hunk = False for i, line in enumerate(patch_lines): if line.startswith('@@'): scan_delta = 0 header_match = re_hunk_header.match(line) - *_, scan_start2 = extract_hunk_headers(header_match) + if header_match: + skip_hunk = False + *_, scan_start2 = extract_hunk_headers(header_match) + else: + skip_hunk = True + get_logger().warning("Skipping a line that starts with '@@' but is not a " + "unified hunk header", artifact={"line": line}) + continue + elif skip_hunk: + continue elif not line.startswith('-'): scan_delta += 1 @@ -1660,11 +1682,21 @@ def scan_patch_lines(is_match): if position == -1 and relevant_line_in_file[0] == '+': no_plus_line = relevant_line_in_file[1:].lstrip() + skip_hunk = False for i, line in enumerate(patch_lines): if line.startswith('@@'): delta = 0 match = re_hunk_header.match(line) - section_header, size1, size2, start1, start2 = extract_hunk_headers(match) + if match: + skip_hunk = False + section_header, size1, size2, start1, start2 = extract_hunk_headers(match) + else: + get_logger().warning("Skipping a line that starts with '@@' but is not a " + "unified hunk header", artifact={"line": line}) + skip_hunk = True + continue + elif skip_hunk: + continue elif not line.startswith('-'): delta += 1 diff --git a/tests/unittest/test_find_line_number_of_relevant_line_in_file.py b/tests/unittest/test_find_line_number_of_relevant_line_in_file.py index 06db14f08d..18e1eb72e4 100644 --- a/tests/unittest/test_find_line_number_of_relevant_line_in_file.py +++ b/tests/unittest/test_find_line_number_of_relevant_line_in_file.py @@ -90,3 +90,54 @@ def test_hunk_header_with_omitted_count_on_the_new_side_only(self): assert position == 1 assert absolute_position == 1 + + def test_combined_diff_header_does_not_crash(self): + """Skip a combined/merge hunk header (@@@) instead of crashing on its None regex match.""" + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch="@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d", filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "c") + + assert position == -1 + assert absolute_position == -1 + + def test_combined_diff_header_absolute_position_does_not_crash(self): + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch="@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d", filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "not_found", absolute_position=3) + + assert position == -1 + + def test_combined_diff_header_plus_line_does_not_crash(self): + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch="@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d", filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "+b") + + assert position == -1 + assert absolute_position == -1 + + def test_a_valid_hunk_after_a_combined_header_is_still_found(self): + """Keep anchoring from a valid hunk rendered after a skipped combined header.""" + combined = "@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d" + valid_hunk = "@@ -3,1 +5,1 @@\n ctx\n-old\n+new" + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch=combined + "\n" + valid_hunk, filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "new") + + assert position == 8 + assert absolute_position == 6 From 5a3dbc7d12c425dff1c5868a1d345fed348a1f54 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Sun, 13 Sep 2026 06:20:23 -0500 Subject: [PATCH 081/238] fix(review): align ticket compliance prompt examples (#3326) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/settings/pr_reviewer_prompts.toml | 4 +-- .../test_pr_reviewer_prompt_contract.py | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pr_agent/settings/pr_reviewer_prompts.toml b/pr_agent/settings/pr_reviewer_prompts.toml index e797cc4536..dacb850ae3 100644 --- a/pr_agent/settings/pr_reviewer_prompts.toml +++ b/pr_agent/settings/pr_reviewer_prompts.toml @@ -149,7 +149,7 @@ review: ... not_compliant_requirements: | ... - overall_compliance_level: | + requires_further_human_verification: | ... {%- endif %} {%- if require_estimate_effort_to_review %} @@ -300,7 +300,7 @@ review: ... not_compliant_requirements: | ... - overall_compliance_level: | + requires_further_human_verification: | ... {%- endif %} {%- if require_estimate_effort_to_review %} diff --git a/tests/unittest/test_pr_reviewer_prompt_contract.py b/tests/unittest/test_pr_reviewer_prompt_contract.py index b4d2fdc824..fba8d4ebeb 100644 --- a/tests/unittest/test_pr_reviewer_prompt_contract.py +++ b/tests/unittest/test_pr_reviewer_prompt_contract.py @@ -55,6 +55,42 @@ def _referenced_variables(half): return meta.find_undeclared_variables(environment.parse(template)) +@pytest.mark.parametrize( + ("duplicate_prompt_examples", "expected_user_examples"), + [(False, 0), (True, 1)], +) +def test_ticket_compliance_examples_use_consumer_field( + monkeypatch, + duplicate_prompt_examples, + expected_user_examples, +): + reviewer = _build_reviewer(monkeypatch) + reviewer.vars["related_tickets"] = [ + SimpleNamespace( + ticket_url="https://tracker.example/tickets/1", + title="Ticket", + labels="", + body="", + requirements="", + ) + ] + reviewer.vars["duplicate_prompt_examples"] = duplicate_prompt_examples + + environment = Environment( + autoescape=select_autoescape(default_for_string=False), + undefined=StrictUndefined, + ) + system_prompt = environment.from_string(get_settings().pr_review_prompt.system).render(reviewer.vars) + user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(reviewer.vars) + + canonical_example = "\n requires_further_human_verification: |" + legacy_example = "\n overall_compliance_level: |" + assert system_prompt.count(canonical_example) == 1 + assert user_prompt.count(canonical_example) == expected_user_examples + assert legacy_example not in system_prompt + assert legacy_example not in user_prompt + + @pytest.mark.parametrize("half", ["system", "user"]) def test_pr_review_prompt_variables_are_all_supplied(monkeypatch, half): reviewer = _build_reviewer(monkeypatch) From a02d4186bdb1bf9d1ddad9b0b067fe362db75334 Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Sun, 13 Sep 2026 19:22:22 +0800 Subject: [PATCH 082/238] perf(gitlab): skip unused project metadata during ticket extraction (#3322) Signed-off-by: Junhuan Zheng <3373484735@qq.com> --- pr_agent/tools/ticket_pr_compliance_check.py | 3 +- tests/unittest/test_gitlab_ticket_requests.py | 125 ++++++++++++++++++ .../unittest/test_ticket_extraction_async.py | 2 +- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/unittest/test_gitlab_ticket_requests.py diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 0b23e8c659..00c8ff3ba9 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -819,7 +819,8 @@ async def extract_tickets(git_provider): tickets_content = [] for project_path, issue_iid in references: try: - project = git_provider.gl.projects.get(project_path) + # Only the issue manager is needed; avoid fetching unused project metadata. + project = git_provider.gl.projects.get(project_path, lazy=True) issue = project.issues.get(issue_iid) except Exception as e: get_logger().error( diff --git a/tests/unittest/test_gitlab_ticket_requests.py b/tests/unittest/test_gitlab_ticket_requests.py new file mode 100644 index 0000000000..37389916f1 --- /dev/null +++ b/tests/unittest/test_gitlab_ticket_requests.py @@ -0,0 +1,125 @@ +"""Exercise ticket extraction through python-gitlab's real HTTP request construction.""" +import asyncio +import json +from types import SimpleNamespace +from urllib.parse import urlparse + +import gitlab +import pytest +import requests +from requests.adapters import BaseAdapter + +from pr_agent.tools import ticket_pr_compliance_check as tickets + + +class IssueTransport(BaseAdapter): + def __init__(self, outcomes=()): + super().__init__() + self.requests = [] + self.outcomes = iter(outcomes) + + def send(self, request, **kwargs): + self.requests.append(request) + outcome = next(self.outcomes, 200) + if isinstance(outcome, BaseException): + raise outcome + # A project metadata request must never reach the transport. + assert request.method == "GET" and "/issues/" in request.url + iid = int(urlparse(request.url).path.rsplit("/", 1)[-1]) + payload = { + "id": iid + 100, + "iid": iid, + "web_url": f"https://gitlab.example/group/repo/-/issues/{iid}", + "title": f"Issue {iid}", + "description": "x" * (tickets.MAX_TICKET_CHARACTERS + 1), + "labels": ["bug", "backend"], + } if outcome == 200 else {"message": "unavailable"} + response = requests.Response() + response.status_code = outcome + response._content = json.dumps(payload).encode() + response.headers["Content-Type"] = "application/json" + response.request = request + return response + + def close(self): + pass + + +@pytest.fixture +def provider_factory(monkeypatch): + # Keep unrelated integrations deterministic; retain the native extraction path. + monkeypatch.setattr(tickets, "add_jira_tickets", lambda provider, content: content) + sessions = [] + + def make(description, outcomes=(), token="offline-token", retry=False): + transport = IssueTransport(outcomes) + session = requests.Session() + session.mount("https://", transport) + sessions.append(session) + client = gitlab.Gitlab("https://gitlab.example/gitlab", private_token=token, session=session, + retry_transient_errors=retry) + provider = SimpleNamespace( + gl=client, id_project="group/repo", gitlab_url="https://gitlab.example/gitlab", + get_user_description=lambda: description, + supports_issue_url_tickets=lambda: False, + supports_issue_reference_tickets=lambda: True, + ) + return provider, transport + + yield make + for session in sessions: + session.close() + + +async def test_extract_issues_without_project_metadata(provider_factory): + provider, transport = provider_factory("Fixes #1 #2 other/sub/project#3 #4 #1") + result = await tickets.extract_tickets(provider) + + assert [ticket["ticket_id"] for ticket in result] == [1, 2, 3] + assert [urlparse(request.url).path for request in transport.requests] == [ + "/gitlab/api/v4/projects/group%2Frepo/issues/1", + "/gitlab/api/v4/projects/group%2Frepo/issues/2", + "/gitlab/api/v4/projects/other%2Fsub%2Fproject/issues/3", + ] + assert all(request.headers["PRIVATE-TOKEN"] == "offline-token" for request in transport.requests) + assert all(ticket["body"] == "x" * tickets.MAX_TICKET_CHARACTERS + "..." for ticket in result) + assert all(ticket["labels"] == "bug, backend" for ticket in result) + + +@pytest.mark.parametrize("failure", [401, 403, 404, 500, requests.Timeout("offline timeout")]) +async def test_issue_failures_preserve_successful_context(provider_factory, failure): + provider, transport = provider_factory("Fixes #1 #2", [failure, 200]) + result = await tickets.extract_tickets(provider) + + assert [ticket["ticket_id"] for ticket in result] == [2] + assert len(transport.requests) == 2 + + +async def test_failed_issue_is_fetched_again_on_next_extraction(provider_factory): + provider, transport = provider_factory("Fixes #1", [403, 200]) + assert await tickets.extract_tickets(provider) == [] + assert [ticket["ticket_id"] for ticket in await tickets.extract_tickets(provider)] == [1] + assert len(transport.requests) == 2 + + +async def test_gitlab_transport_retry_remains_enabled(provider_factory, monkeypatch): + monkeypatch.setattr("gitlab.utils.time.sleep", lambda seconds: None) + provider, transport = provider_factory("Fixes #1", [503, 200], retry=True) + assert [ticket["ticket_id"] for ticket in await tickets.extract_tickets(provider)] == [1] + assert len(transport.requests) == 2 + + +async def test_cancellation_propagates_without_fetching_later_issues(provider_factory): + provider, transport = provider_factory("Fixes #1 #2", [asyncio.CancelledError()]) + with pytest.raises(asyncio.CancelledError): + await tickets.extract_tickets(provider) + assert len(transport.requests) == 1 + + +async def test_separate_credentials_do_not_share_results(provider_factory): + allowed, allowed_transport = provider_factory("Fixes #1", token="allowed-offline-token") + denied, denied_transport = provider_factory("Fixes #1", [403], token="denied-offline-token") + assert len(await tickets.extract_tickets(allowed)) == 1 + assert await tickets.extract_tickets(denied) == [] + assert allowed_transport.requests[0].headers["PRIVATE-TOKEN"] == "allowed-offline-token" + assert denied_transport.requests[0].headers["PRIVATE-TOKEN"] == "denied-offline-token" diff --git a/tests/unittest/test_ticket_extraction_async.py b/tests/unittest/test_ticket_extraction_async.py index b222679546..6644e284ed 100644 --- a/tests/unittest/test_ticket_extraction_async.py +++ b/tests/unittest/test_ticket_extraction_async.py @@ -679,7 +679,7 @@ def test_issue_reference_is_mapped_to_ticket_context(self, reference, settings_s "labels": "bug, backend", } ] - provider.gl.projects.get.assert_called_once_with("group/repo") + provider.gl.projects.get.assert_called_once_with("group/repo", lazy=True) project.issues.get.assert_called_once_with(7) From d2d3eeae38e47036204f2d3fba2f365381b21e87 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:07:32 +0545 Subject: [PATCH 083/238] fix(ask): keep image blocks for user-message-only models (#3339) --- .../algo/ai_handlers/litellm_ai_handler.py | 7 ++- .../test_litellm_chat_completion_core.py | 63 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 22e06a49a8..c30583596c 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -3973,7 +3973,12 @@ async def _chat_completion_with_retry( user = f"{system}\n\n\n{user}" system = "" get_logger().info(f"Using model {model}, combining system and user prompts") - messages = [{"role": "user", "content": user}] + if img_path: + content = [{"type": "text", "text": user}, + {"type": "image_url", "image_url": {"url": img_path}}] + else: + content = user + messages = [{"role": "user", "content": content}] # Build request kwargs after normalizing the model and messages so credentials and # endpoints can be selected for the provider that will actually receive this call. diff --git a/tests/unittest/test_litellm_chat_completion_core.py b/tests/unittest/test_litellm_chat_completion_core.py index c08c8dcac4..f9d3ec6a38 100644 --- a/tests/unittest/test_litellm_chat_completion_core.py +++ b/tests/unittest/test_litellm_chat_completion_core.py @@ -464,6 +464,69 @@ async def test_chat_completion_combines_prompts_for_user_message_only_models(mon assert messages == [{"role": "user", "content": "sys\n\n\nusr"}] +@pytest.mark.asyncio +async def test_chat_completion_keeps_image_for_user_message_only_models(monkeypatch): + monkeypatch.setattr(litellm_handler, "get_settings", FakeSettings) + monkeypatch.setattr( + litellm_handler.requests, + "head", + lambda *args, **kwargs: SimpleNamespace(status_code=200), + ) + + with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", new_callable=AsyncMock) as mock_call: + mock_call.return_value = _mock_response() + handler = litellm_handler.LiteLLMAIHandler() + handler.user_message_only_models = ["user-only-model"] + + await handler.chat_completion( + model="user-only-model", + system="sys", + user="usr", + img_path="https://example.test/image.png", + ) + + messages = mock_call.call_args.kwargs["messages"] + assert messages == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "sys\n\n\nusr"}, + {"type": "image_url", "image_url": {"url": "https://example.test/image.png"}}, + ], + } + ] + assert any(block.get("type") == "image_url" for block in messages[0]["content"]) + + +@pytest.mark.asyncio +async def test_chat_completion_keeps_image_for_custom_reasoning_models(monkeypatch): + settings = FakeSettings() + settings.config.custom_reasoning_model = True + monkeypatch.setattr(litellm_handler, "get_settings", lambda: settings) + monkeypatch.setattr( + litellm_handler.requests, + "head", + lambda *args, **kwargs: SimpleNamespace(status_code=200), + ) + + with patch("pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", new_callable=AsyncMock) as mock_call: + mock_call.return_value = _mock_response() + handler = litellm_handler.LiteLLMAIHandler() + + await handler.chat_completion( + model="gpt-4o", + system="sys", + user="usr", + img_path="https://example.test/image.png", + ) + + messages = mock_call.call_args.kwargs["messages"] + assert messages[0]["content"][1] == { + "type": "image_url", + "image_url": {"url": "https://example.test/image.png"}, + } + + # Wiring tests for the retry knobs: the helpers (_should_retry_same_model, # _configured_client_retries) are unit-tested in test_litellm_retry_config.py, but those # tests keep passing when the @retry predicate or the kwargs pass-through in From 2eb64fe2392dc0cdd7b501685f49b89e50808de3 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:08:54 +0545 Subject: [PATCH 084/238] fix(bitbucket): return empty languages when repo has none (#3341) --- pr_agent/git_providers/bitbucket_provider.py | 4 ++-- tests/unittest/test_bitbucket_provider.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index b6ad38335b..413548db53 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -517,8 +517,8 @@ def get_title(self): return self.pr.title def get_languages(self): - languages = {self._get_repo().get_data("language"): 0} - return languages + language = self._get_repo().get_data("language") + return {language: 0} if language else {} def get_pr_branch(self): return self.pr.source_branch diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index 21a4110132..d7b1350199 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -678,6 +678,26 @@ def test_persistent_review_update_links_status_to_existing_cloud_comment(self): "https://bitbucket.org/acme/repo/commits/head" ) + + def test_get_languages_returns_empty_map_when_repo_has_no_language(self): + # Bitbucket Cloud reports "language": null for repos it cannot classify; + # get_languages() must not emit a {None: 0} key or sort_files_by_main_languages() + # crashes on None.lower() (issue #3340). + repo = MagicMock() + repo.get_data.side_effect = lambda key: None + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.repo = repo + + assert provider.get_languages() == {} + + def test_get_languages_includes_detected_language(self): + repo = MagicMock() + repo.get_data.side_effect = lambda key: "Python" + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.repo = repo + + assert provider.get_languages() == {"Python": 0} + class TestBitbucketServerProvider: @staticmethod def _code_suggestion(line: int): From 417b300ecd70aee5621a434b23e0e95d9e86762f Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:09:05 +0545 Subject: [PATCH 085/238] docs: drop dead large-PRs link from usage index (#3342) --- docs/docs/usage-guide/index.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs/usage-guide/index.md b/docs/docs/usage-guide/index.md index 3fa11eb02d..3f37280265 100644 --- a/docs/docs/usage-guide/index.md +++ b/docs/docs/usage-guide/index.md @@ -21,6 +21,5 @@ It includes information on how to adjust PR-Agent configurations, define which t - [Additional Configurations](./additional_configurations.md) - [Ignoring files from analysis](./additional_configurations.md#ignoring-files-from-analysis) - [Extra instructions](./additional_configurations.md#extra-instructions) - - [Working with large PRs](./additional_configurations.md#working-with-large-prs) - [Changing a model](./changing_a_model.md) - [FAQ](../faq/index.md) From bd7078264e17fe4dd0f1629eb1d02a5494417024 Mon Sep 17 00:00:00 2001 From: Takao <44014204+high-tail@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:34:39 +0900 Subject: [PATCH 086/238] refactor(litellm_ai_handler): deduplicate config coercion, API key capture, and AWS region resolution (#3320) --- .../algo/ai_handlers/litellm_ai_handler.py | 98 ++++++++----------- 1 file changed, 43 insertions(+), 55 deletions(-) diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index c30583596c..46624faf49 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -191,6 +191,15 @@ "zai": ("ZAI_API_BASE",), } +_WATSONX_ROUTING_ENV_VARS = { + "api_base": ("WATSONX_API_BASE", "WATSONX_URL", "WX_URL", "WML_URL"), + "project_id": ("WATSONX_PROJECT_ID", "WX_PROJECT_ID", "PROJECT_ID"), + "space_id": ("WATSONX_DEPLOYMENT_SPACE_ID", "WATSONX_SPACE_ID", "WX_SPACE_ID", "SPACE_ID"), + "region_name": ("WATSONX_REGION", "WX_REGION", "REGION"), + "token": ("WATSONX_TOKEN",), + "zen_api_key": ("WATSONX_ZENAPIKEY",), +} + PROVIDER_ROUTING_ENV_VARS = { "azure": { "api_base": ("AZURE_API_BASE", "AZURE_OPENAI_ENDPOINT"), @@ -215,22 +224,8 @@ "vertex_project": ("VERTEXAI_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT"), "vertex_location": ("VERTEXAI_LOCATION", "VERTEX_LOCATION"), }, - "watsonx": { - "api_base": ("WATSONX_API_BASE", "WATSONX_URL", "WX_URL", "WML_URL"), - "project_id": ("WATSONX_PROJECT_ID", "WX_PROJECT_ID", "PROJECT_ID"), - "space_id": ("WATSONX_DEPLOYMENT_SPACE_ID", "WATSONX_SPACE_ID", "WX_SPACE_ID", "SPACE_ID"), - "region_name": ("WATSONX_REGION", "WX_REGION", "REGION"), - "token": ("WATSONX_TOKEN",), - "zen_api_key": ("WATSONX_ZENAPIKEY",), - }, - "watsonx_text": { - "api_base": ("WATSONX_API_BASE", "WATSONX_URL", "WX_URL", "WML_URL"), - "project_id": ("WATSONX_PROJECT_ID", "WX_PROJECT_ID", "PROJECT_ID"), - "space_id": ("WATSONX_DEPLOYMENT_SPACE_ID", "WATSONX_SPACE_ID", "WX_SPACE_ID", "SPACE_ID"), - "region_name": ("WATSONX_REGION", "WX_REGION", "REGION"), - "token": ("WATSONX_TOKEN",), - "zen_api_key": ("WATSONX_ZENAPIKEY",), - }, + "watsonx": dict(_WATSONX_ROUTING_ENV_VARS), + "watsonx_text": dict(_WATSONX_ROUTING_ENV_VARS), } # Keep aliases in the same precedence order as LiteLLM 1.100.0. @@ -1898,6 +1893,15 @@ def _as_bool(value, default: bool) -> bool: return default +def _as_list(value) -> list: + """Parse a config value that may arrive as an list[str] (toml) or a string (env override).""" + if isinstance(value, (list, tuple)): + return [str(v).strip() for v in value if str(v).strip()] + if isinstance(value, str): + return [v.strip() for v in value.split(",") if v.strip()] + return [] + + def _configured_client_retries(): """config.num_retries as a non-negative int, or None (unset/invalid = client defaults). @@ -2237,12 +2241,7 @@ def _snapshot_provider_request_params(self, settings) -> dict: provider_params.setdefault(provider, {})[parameter] = value break - aws_region = ( - os.environ.get("AWS_REGION_NAME") - or settings.get("aws.AWS_REGION_NAME", None) - or os.environ.get("AWS_REGION") - or os.environ.get("AWS_DEFAULT_REGION") - ) + aws_region = self._resolve_aws_region(settings) if aws_region: provider_params.setdefault("bedrock", {})["aws_region_name"] = aws_region mantle_aws_region = aws_region @@ -2441,6 +2440,23 @@ def _snapshot_provider_environment_api_keys() -> dict: provider_api_keys[provider] = api_key return provider_api_keys + def _captured_api_key(self, provider: str): + """Return the API key captured for a provider, preferring configured params over the environment.""" + return ( + getattr(self, "_provider_request_params", {}).get(provider, {}).get("api_key") + or getattr(self, "_provider_environment_api_keys", {}).get(provider) + ) + + @staticmethod + def _resolve_aws_region(settings) -> str | None: + """Resolve the AWS region from the environment and the configured aws settings.""" + return ( + os.environ.get("AWS_REGION_NAME") + or settings.get("aws.AWS_REGION_NAME", None) + or os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + ) + @staticmethod def _snapshot_request_headers(settings) -> dict: """Capture explicitly configured headers for every request from this handler.""" @@ -2463,12 +2479,7 @@ def _initialize_aws_request_credentials(self, settings) -> None: variable: os.environ.get(variable) for variable in AWS_CREDENTIAL_CHAIN_ENV_VARS } - request_region = ( - os.environ.get("AWS_REGION_NAME") - or settings.get("aws.AWS_REGION_NAME", None) - or os.environ.get("AWS_REGION") - or os.environ.get("AWS_DEFAULT_REGION") - ) + request_region = self._resolve_aws_region(settings) ambient_access_key = os.environ.get("AWS_ACCESS_KEY_ID") ambient_secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY") if bool(ambient_access_key) != bool(ambient_secret_key): @@ -3406,20 +3417,6 @@ def _apply_openrouter_request_controls( # Normalize operator-controlled config: Dynaconf/env overrides can # arrive as strings (AUTO_CAST_FOR_DYNACONF is disabled), so coerce # defensively instead of trusting the declared types. - def _as_list(value): - if isinstance(value, (list, tuple)): - return [str(v).strip() for v in value if str(v).strip()] - if isinstance(value, str): - return [v.strip() for v in value.split(",") if v.strip()] - return [] - - def _as_bool(value, default=True): - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in ("1", "true", "yes", "on") - return default - provider_only = _as_list(openrouter_settings.get("provider_only", [])) provider_order = _as_list(openrouter_settings.get("provider_order", [])) if provider_only: @@ -3427,7 +3424,7 @@ def _as_bool(value, default=True): elif provider_order: provider = extra_body.setdefault("provider", {}) provider["order"] = provider_order - provider["allow_fallbacks"] = _as_bool(openrouter_settings.get("allow_fallbacks", True)) + provider["allow_fallbacks"] = _as_bool(openrouter_settings.get("allow_fallbacks", True), default=True) reasoning = {} effective_reasoning_effort = str( @@ -4340,10 +4337,7 @@ async def _acompletion( and _request_local_openai_headers(transport, model=transport_model) is not None ) if provider == "azure" and azure_ad_token and kwargs.get("api_key") is not None: - captured_key = ( - self._provider_request_params.get("azure", {}).get("api_key") - or self._provider_environment_api_keys.get("azure") - ) + captured_key = self._captured_api_key("azure") model = kwargs.get("model", "") headers = dict(kwargs.get("headers") or {}) guard_key = kwargs.get("api_key") == DUMMY_LITELLM_API_KEY and (self._azure_ad or not captured_key) @@ -4420,20 +4414,14 @@ async def _acompletion( vertex_aws_token = _vertex_request_aws_environment.set(self._vertex_aws_environment) vertex_default_token = _vertex_request_default_adc.set(adc_snapshot) if provider == "databricks": - captured_key = ( - getattr(self, "_provider_request_params", {}).get("databricks", {}).get("api_key") - or getattr(self, "_provider_environment_api_keys", {}).get("databricks") - ) + captured_key = self._captured_api_key("databricks") keyless = kwargs.get("api_key") == DUMMY_LITELLM_API_KEY and not captured_key if keyless: _install_databricks_keyless_bridge() databricks_token = _databricks_request_keyless.set(keyless) if provider == "anthropic": _install_anthropic_auth_token_bridge() - captured_key = ( - getattr(self, "_provider_request_params", {}).get("anthropic", {}).get("api_key") - or getattr(self, "_provider_environment_api_keys", {}).get("anthropic") - ) + captured_key = self._captured_api_key("anthropic") anthropic_token = _anthropic_request_auth_token.set({ "auth_token": getattr(self, "_anthropic_auth_token", None), "generated_guard": kwargs.get("api_key") == DUMMY_LITELLM_API_KEY and not captured_key, From d24b6f36e7871829ad8f29e884b4577fb3902f16 Mon Sep 17 00:00:00 2001 From: Fayupable <90789180+Fayupable@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:39:53 +0300 Subject: [PATCH 087/238] feat(jira): add project-key allowlist to skip lookups for key-shaped noise (#3304) Co-authored-by: L4XB Co-authored-by: Ricky-7-Yan <224488344+Ricky-7-Yan@users.noreply.github.com> --- .../core-abilities/fetching_ticket_context.md | 20 ++ .../usage-guide/configuration_reference.md | 1 + pr_agent/settings/configuration.toml | 6 + pr_agent/tools/ticket_pr_compliance_check.py | 89 ++++++++ tests/unittest/test_config_reference.py | 2 +- tests/unittest/test_jira_ticket_extraction.py | 210 ++++++++++++++++++ 6 files changed, 327 insertions(+), 1 deletion(-) diff --git a/docs/docs/core-abilities/fetching_ticket_context.md b/docs/docs/core-abilities/fetching_ticket_context.md index 46a001d6a8..64e5c64445 100644 --- a/docs/docs/core-abilities/fetching_ticket_context.md +++ b/docs/docs/core-abilities/fetching_ticket_context.md @@ -197,6 +197,26 @@ instance (for example `customfield_10127`); leave it empty to skip requirements. jira_requirements_field = "customfield_10127" ``` +#### Project key allowlist (optional) + +Ticket detection matches any `PROJECT-123` shaped text, so strings like `SHA-256`, +`UTF-8` or `ISO-8601` in a title or description each cost an authenticated lookup that +returns 404. If your repository works with a known set of Jira projects, list their keys +in `project_keys`; keys with any other prefix are then dropped before any lookup (they are +named once at debug level in the log). Leave the list empty to look up every key found. + +```toml +[jira] +project_keys = ["PROJ", "OPS"] +``` + +Entries are plain upper-case project keys (letters only, as Jira writes them); anything +else (a lower-case label, a full ticket key, a URL, a blank entry) is ignored with a +warning. If the list is set but none of its entries is valid, no Jira lookup is made at all +until it is fixed, so a typo cannot silently widen the lookup again. Only a missing option, +the empty list, and an environment override set to the empty string mean "look up every +key". + ### How to link a PR to a Jira ticket To integrate with Jira, you can link your PR to a ticket using either of these methods: diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 782a0c99c4..ced2974895 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -401,6 +401,7 @@ _This section only documents commented-out examples; see the [TOML source](https | Key | Default | Description | | --- | --- | --- | | `jira_requirements_field` | "" | Custom field id holding acceptance criteria / requirements, mapped to the ticket "requirements" section. Instance-specific (e.g. "customfield_10127"); empty disables it. | +| `project_keys` | [] | Optional allowlist of Jira project keys, e.g. ["PROJ", "OPS"]. When non-empty, key-shaped text with another prefix ("SHA-256", "UTF-8", "ISO-8601") is dropped before any lookup, so it no longer costs an authenticated 404 each. Entries must be plain upper-case keys; a supplied list with no valid entry disables the lookup rather than widening it. Empty (default) looks up every key found. | ## `[litellm]` diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 4e6cd8ebe8..fc7eb969dd 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -379,6 +379,12 @@ url = "" # URL to the BitBucket Server instance # Custom field id holding acceptance criteria / requirements, mapped to the ticket # "requirements" section. Instance-specific (e.g. "customfield_10127"); empty disables it. jira_requirements_field = "" +# Optional allowlist of Jira project keys, e.g. ["PROJ", "OPS"]. When non-empty, key-shaped +# text with another prefix ("SHA-256", "UTF-8", "ISO-8601") is dropped before any lookup, +# so it no longer costs an authenticated 404 each. Entries must be plain upper-case keys; +# a supplied list with no valid entry disables the lookup rather than widening it. +# Empty (default) looks up every key found. +project_keys = [] [litellm] # use_client = false diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 00c8ff3ba9..eabc92aad3 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -52,6 +52,15 @@ # cannot contain '.', '/', ':', '@' etc. that would let it escape *.atlassian.net. JIRA_SITE_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE) +# A Jira project key as configured in jira.project_keys: the "" in PROJECT-123. +# Bounded like the prefix in find_jira_tickets (2-10 letters) so an entry can only ever be +# a plain label, never a URL or a full ticket key. Case-sensitive: Jira project keys are +# upper case, so a configured "proj" is a typo rather than a spelling variant, and +# accepting it silently would widen the lookup to a project the repo never named. Keys +# found in the PR text are still matched case-insensitively and upper-cased +# (find_jira_tickets) before they are compared. +JIRA_PROJECT_KEY_PATTERN = re.compile(r"^[A-Z]{2,10}$") + def _jira_cloud_base_url(): """ @@ -73,6 +82,63 @@ def _jira_cloud_base_url(): return f"https://{site}.atlassian.net" +def _jira_project_keys(): + """ + Return the configured jira.project_keys allowlist as a set of project keys, or None + when nothing was supplied (look up every key found). + + Only three values count as "nothing was supplied": the option missing entirely, the + shipped empty list, and a top-level string that is blank, the last because + `jira__project_keys=""` is how a shell spells an unset environment variable. + + Everything else is a supplied value and is judged on its entries. Entries that are not + plain upper-case project keys, including a blank one (so `[""]` is a malformed + override rather than a second spelling of the default), are ignored with a warning, + and a supplied value left with no valid entry yields an empty set, which drops every + key. So does a value that is not a list at all (a boolean or number from a YAML or CLI + override). A typo fails closed instead of silently widening the lookup to every + key-shaped string. Accepts a list or a comma-separated string, the latter for + environment-variable overrides (jira__project_keys="PROJ,OPS"). + """ + configured = get_settings().get("JIRA.PROJECT_KEYS", None) + if configured is None: + return None + if isinstance(configured, str): + if not configured.strip(): + return None + configured = configured.split(",") + elif not isinstance(configured, (list, tuple)): + # A YAML/CLI override such as `project_keys=true` or `=false` is neither + # "unset" nor a list; fail closed rather than guess what it meant. + get_logger().warning( + f"jira.project_keys must be a list of project keys, got {type(configured).__name__}; " + "skipping Jira ticket lookup until it is fixed") + return set() + if not configured: + # The shipped default. An empty container carries no entry to be wrong about. + return None + allowed = set() + for index, item in enumerate(configured): + # Only strings are candidates: a TOML/YAML boolean or null in the list must not + # be stringified into a key-shaped label ("TRUE", "NONE") that then filters. + key = item.strip() if isinstance(item, str) else item + if not isinstance(key, str) or not JIRA_PROJECT_KEY_PATTERN.match(key): + # The entry is named by position and type, never quoted back. Its content is + # configuration this tool does not control: it can be long, hold a secret + # pasted into the wrong setting, or carry newlines that would forge log + # lines. The position is what an operator needs to find and fix it. + get_logger().warning( + f"Ignoring invalid jira.project_keys entry at index {index} " + f"(type {type(item).__name__}); expected a plain upper-case project key " + "like 'PROJ'") + continue + allowed.add(key) + if not allowed: + get_logger().warning( + "jira.project_keys has no valid entry; skipping Jira ticket lookup until it is fixed") + return allowed + + def find_jira_tickets(text): # Regular expression patterns for JIRA tickets. Matching is case-insensitive so # lowercased branch names (e.g. bugfix/abc-123-description) are detected; keys are @@ -151,6 +217,9 @@ def extract_jira_tickets(text, max_characters=MAX_TICKET_CHARACTERS, max_tickets MAX_JIRA_FETCH_ATTEMPTS lookups have been made. Counting resolved tickets rather than truncating the candidate list keeps key-shaped noise ("SHA-256", "UTF-8") from displacing a real ticket that appears later in the text. + + When jira.project_keys is configured, candidates whose prefix isn't in that allowlist + are dropped before any lookup, so they don't consume a fetch attempt. """ # Look for keys before building a client: most PRs have none, and building the # client first would do needless work (and log a noisy init failure if Jira is @@ -159,6 +228,26 @@ def extract_jira_tickets(text, max_characters=MAX_TICKET_CHARACTERS, max_tickets if not keys: return [] + # Optional allowlist: key-shaped false positives like "SHA-256" or "UTF-8" match the + # regex but aren't real Jira keys. Filtering by known project prefixes here means they + # never consume one of the MAX_JIRA_FETCH_ATTEMPTS lookups below. Empty keeps today's + # behaviour; a supplied but invalid allowlist drops every key (see _jira_project_keys). + allowed_projects = _jira_project_keys() + if allowed_projects is not None: + # Partitioned in one pass: the candidate list is not capped until lookups begin, + # so a PR carrying many distinct key-shaped tokens would otherwise pay a + # membership scan of the skipped list for every one of them. + kept, skipped = [], [] + for key in keys: + target = kept if key.split("-", 1)[0] in allowed_projects else skipped + target.append(key) + if skipped: + get_logger().debug( + f"Skipping Jira lookup for keys outside jira.project_keys: {', '.join(skipped)}") + keys = kept + if not keys: + return [] + jira_client = _get_jira_client() if jira_client is None: return [] diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index 5bb7c1d93b..e0adc5f584 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 255 + assert sum(keys.values()) == 256 assert { "model", diff --git a/tests/unittest/test_jira_ticket_extraction.py b/tests/unittest/test_jira_ticket_extraction.py index b5272488b1..48f54a5731 100644 --- a/tests/unittest/test_jira_ticket_extraction.py +++ b/tests/unittest/test_jira_ticket_extraction.py @@ -1,3 +1,4 @@ +from string import ascii_uppercase from unittest.mock import MagicMock, patch from urllib.parse import urlparse @@ -25,6 +26,7 @@ "JIRA.JIRA_API_EMAIL", "JIRA.JIRA_API_TOKEN", "JIRA.JIRA_REQUIREMENTS_FIELD", + "JIRA.PROJECT_KEYS", ) @@ -271,6 +273,214 @@ def fake_issue(key): result = extract_jira_tickets("abc-123 utf-8") assert [t["ticket_id"] for t in result] == ["ABC-123"] + def test_project_keys_allowlist_filters_noise_before_fetch(self): + """When jira.project_keys is set, key-shaped noise outside the allowlist is + dropped before any lookup: it never reaches jira_client.issue().""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", ["PROJ"]) + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("Switch SHA-256 digest per PROJ-4242") + client.issue.assert_called_once_with("PROJ-4242") + assert [t["ticket_id"] for t in result] == ["PROJ-4242"] + + def test_project_keys_allowlist_drops_unlisted_prefixes_before_lookup(self): + """With several projects listed, every listed prefix is kept in first-seen order + and every other prefix ("SHA-256", "UTF-8", "ISO-8601") costs no authenticated 404.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", ["PROJ", "OPS"]) + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("PROJ-1 uses SHA-256 and UTF-8, see OPS-7 and ISO-8601") + assert [call.args[0] for call in client.issue.call_args_list] == ["PROJ-1", "OPS-7"] + assert [t["ticket_id"] for t in result] == ["PROJ-1", "OPS-7"] + + def test_project_keys_allowlist_empty_result_skips_client(self): + """When every candidate is filtered out by the allowlist, no Jira client is + built at all: same early-exit as having no keys in the text.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", ["PROJ"]) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls: + result = extract_jira_tickets("SHA-256 and UTF-8 only, no real ticket") + assert result == [] + jira_cls.assert_not_called() + + def test_uppercase_allowlist_still_matches_a_lowercased_ticket_key(self): + """Detected keys are normalized to upper case before the allowlist is applied, so a + lowercased branch name (bugfix/proj-12-x) matches an upper-case entry.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", ["PROJ"]) + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("bugfix/proj-12-x mentions SHA-256") + assert [call.args[0] for call in client.issue.call_args_list] == ["PROJ-12"] + assert [t["ticket_id"] for t in result] == ["PROJ-12"] + + @pytest.mark.parametrize("entries", [["proj"], ["Proj"], "proj, ops"]) + def test_lowercase_project_keys_fail_closed(self, entries): + """Jira project keys are upper case, so a lower-case entry is a typo rather than a + spelling variant. Accepting it would allow a project the repo never named, so it is + rejected with a warning and no lookup happens at all.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", entries) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls, \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + result = extract_jira_tickets("bugfix/proj-12-x mentions SHA-256") + assert result == [] + jira_cls.assert_not_called() + warning_calls = [str(c) for c in get_logger.return_value.warning.call_args_list] + assert any("project_keys" in c and "index 0" in c for c in warning_calls) + assert any("no valid entry" in c for c in warning_calls) + + def test_project_keys_accept_comma_separated_string(self): + """Environment-variable overrides arrive as a string (jira__project_keys="PROJ, OPS").""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", "PROJ, OPS") + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + extract_jira_tickets("OPS-3 SHA-256 PROJ-4") + assert [call.args[0] for call in client.issue.call_args_list] == ["OPS-3", "PROJ-4"] + + @pytest.mark.parametrize("bad_entry", [ + "https://acme.atlassian.net/browse/PROJ", # a URL, not a key + "PROJ-123", # a ticket key, not a project key + "P", # too short to ever match a ticket + "PROJECT_KEY_TOO_LONG", # longer than the ticket pattern allows + "pro j", # whitespace + ]) + def test_invalid_project_keys_are_ignored_with_a_warning(self, bad_entry): + """A malformed entry cannot widen the allowlist; it is dropped with a warning while + the valid entries keep filtering.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", [bad_entry, "PROJ"]) + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client), \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + extract_jira_tickets("PROJ-1 SHA-256") + assert [call.args[0] for call in client.issue.call_args_list] == ["PROJ-1"] + warning_calls = [str(c) for c in get_logger.return_value.warning.call_args_list] + # Named by position, not quoted back: see + # test_a_rejected_entry_is_never_echoed_into_the_log. + assert any("project_keys" in c and "index 0" in c for c in warning_calls) + + def test_a_rejected_entry_is_never_echoed_into_the_log(self): + """The warning names the entry's position and type, never its content. + + project_keys is operator configuration this tool does not control. A value pasted + into the wrong setting can be a credential, and one carrying newlines could forge + whole log lines, so the rejected text is not quoted back. + """ + self._configure_jira() + secret = "hunter2-SECRET\nWARNING: Jira ticket lookup disabled by operator" + get_settings().set("JIRA.PROJECT_KEYS", [secret, "PROJ"]) + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client), \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + extract_jira_tickets("PROJ-1 SHA-256") + # The valid entry still filters, so the rejection is not a silent drop. + assert [call.args[0] for call in client.issue.call_args_list] == ["PROJ-1"] + warnings = [str(c) for c in get_logger.return_value.warning.call_args_list] + assert any("project_keys" in w and "index 0" in w and "type str" in w for w in warnings) + for w in warnings: + assert "hunter2" not in w + assert "SECRET" not in w + assert "\n" not in w + assert "disabled by operator" not in w + + def test_filtering_keeps_order_and_reports_every_skipped_key(self): + """Candidates are partitioned in a single pass over the list. + + The candidate list is not capped until lookups begin, so a PR carrying many + distinct key-shaped tokens is the shape this has to stay linear on. What the + caller sees must not change: kept keys keep their first-seen order and the debug + line still names every skipped key. + """ + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", ["PROJ"]) + noise = [f"X{letter}-{index}" for index, letter in enumerate(ascii_uppercase, start=1)] + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client), \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + extract_jira_tickets(" ".join(["PROJ-1", *noise, "PROJ-2"])) + assert [call.args[0] for call in client.issue.call_args_list] == ["PROJ-1", "PROJ-2"] + debug_calls = [str(c) for c in get_logger.return_value.debug.call_args_list] + assert any( + "Skipping Jira lookup for keys outside jira.project_keys" in c + and ", ".join(noise) in c + for c in debug_calls + ) + + @pytest.mark.parametrize("entries", [["PROJ-123"], ["https://acme.atlassian.net/browse/PROJ", "P"], "PROJ-123, x"]) + def test_project_keys_with_no_valid_entry_fail_closed(self, entries): + """A configured allowlist whose entries are all malformed must not widen to the + unfiltered default: nothing is looked up and no client is built until it is fixed.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", entries) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls, \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + result = extract_jira_tickets("PROJ-1 SHA-256") + assert result == [] + jira_cls.assert_not_called() + warning_calls = [str(c) for c in get_logger.return_value.warning.call_args_list] + assert any("no valid entry" in c for c in warning_calls) + + @pytest.mark.parametrize("value", [True, False, 0, 1, 1.5, {"PROJ": True}]) + def test_project_keys_of_an_unsupported_type_fail_closed(self, value): + """`project_keys=true` / `=false` from a YAML or CLI override is neither unset nor a + list; it must not raise and must not re-enable lookups for every key.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", value) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls, \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + result = extract_jira_tickets("PROJ-1 SHA-256") + assert result == [] + jira_cls.assert_not_called() + warning_calls = [str(c) for c in get_logger.return_value.warning.call_args_list] + assert any("must be a list" in c for c in warning_calls) + + @pytest.mark.parametrize("bad_entry", [True, False, None, 0]) + def test_non_string_project_keys_are_not_stringified_into_labels(self, bad_entry): + """A boolean or null in the list must not become a key-shaped label ("TRUE", + "NONE") that silently filters; it is ignored and the valid entry keeps working.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", [bad_entry, "PROJ"]) + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client), \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + extract_jira_tickets("PROJ-1 TRUE-2 NONE-3 SHA-256") + assert [call.args[0] for call in client.issue.call_args_list] == ["PROJ-1"] + warning_calls = [str(c) for c in get_logger.return_value.warning.call_args_list] + assert any("project_keys" in c for c in warning_calls) + + @pytest.mark.parametrize("value", [[], (), "", " "]) + def test_unsupplied_project_keys_keep_looking_up_every_key(self, value): + """The three ways of supplying nothing: the option missing (covered by the tests + above), the shipped empty list, and a blank top-level string, since + `jira__project_keys=""` is how a shell spells an unset environment variable. All + keep today's behaviour.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", value) + client = self._fake_client() + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira", return_value=client): + result = extract_jira_tickets("PROJ-1 SHA-256") + assert [call.args[0] for call in client.issue.call_args_list] == ["PROJ-1", "SHA-256"] + assert len(result) == 2 + + @pytest.mark.parametrize("entries", [[""], [" "], ["", ""], ",", " , "]) + def test_supplied_blank_project_keys_fail_closed(self, entries): + """A blank *entry* is a malformed override, not a second spelling of the default: it + carries a list or a separator, so something was supplied and got lost. Widening back + to unfiltered lookup there would let a broken override grant access it never named.""" + self._configure_jira() + get_settings().set("JIRA.PROJECT_KEYS", entries) + with patch("pr_agent.tools.ticket_pr_compliance_check.Jira") as jira_cls, \ + patch("pr_agent.tools.ticket_pr_compliance_check.get_logger") as get_logger: + result = extract_jira_tickets("PROJ-1 SHA-256") + assert result == [] + jira_cls.assert_not_called() + warning_calls = [str(c) for c in get_logger.return_value.warning.call_args_list] + assert any("no valid entry" in c for c in warning_calls) + class TestGetJiraClient: """Cloud client construction: site-name -> base URL, email+token auth, v2 pin.""" From 2c5967ea1a32356fe7c6f6cbdd986463b059c149 Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Mon, 14 Sep 2026 16:00:39 +0800 Subject: [PATCH 088/238] feat(improve): recover failed chunks with fallback models (#3324) Signed-off-by: hwan <3373484735@qq.com> --- docs/docs/tools/improve.md | 27 + pr_agent/algo/pr_processing.py | 17 +- pr_agent/tools/pr_code_suggestions.py | 180 +++++-- .../test_suggestion_chunk_recovery.py | 480 ++++++++++++++++++ 4 files changed, 669 insertions(+), 35 deletions(-) create mode 100644 tests/unittest/test_suggestion_chunk_recovery.py diff --git a/docs/docs/tools/improve.md b/docs/docs/tools/improve.md index 4372c0cc70..89a6621808 100644 --- a/docs/docs/tools/improve.md +++ b/docs/docs/tools/improve.md @@ -407,6 +407,33 @@ for the authoritative default values.
    require_ticket_analysis_reviewIf set to true, and the PR contains a GitHub or Jira ticket link, the tool will add a section that checks if the PR in fact fulfilled the ticket requirements. + If set to true and ticket context is available, the tool adds a ticket-compliance section to the review comment + that checks whether the PR fulfills the ticket requirements. Supported sources include GitHub and GitLab issues, + linked Azure DevOps work items, Jira Cloud tickets, and Asana tasks. +
    require_risk_assessment
    persistent_finding_stateIf set to true, PR-Agent persists structured review finding state across complete review runs, so findings can be resolved and reopened. Incremental and partial reviews do not resolve absent findings. Default is true.If set to true, PR-Agent persists structured review finding state across complete review runs, so findings can be resolved and reopened. Resolved findings retain the original Markdown formatting of their description. Incremental and partial reviews do not resolve absent findings. Default is true.
    +## Recovering a partially failed analysis + +A partially failed analysis automatically tries the remaining configured +`config.fallback_models` for the chunks that raised an exception after other chunks succeeded, +with no extra configuration beyond `config.fallback_models` itself. +Successful predictions are retained in their original order. Recovery runs model by model, +after the preceding batch finishes, using the matching `openai.fallback_deployments` where configured, +and follows the effective fallback chain of the invocation, including a primary model selected by +[model routing](../usage-guide/changing_a_model.md#routing-small-pull-requests-to-a-cheaper-model). +A fallback model that recovers at least one chunk is marked as a fallback in the run +details, so the published run line still reports the primary model while the sticky fallback flag +stays set. + +Each remaining model is tried at most once per still-failed chunk at the tool level; the model +handler's existing retries and self-reflection calls can add provider requests. A chunk whose complete +generation prompt does not fit a fallback model's token budget is skipped for that model, without +truncating its context; the reserved headroom follows the output allowance the AI handler will +actually request for that model (`config.max_output_tokens` and related controls), falling back to +the fixed soft threshold when no specific limit is exposed. Later, larger models can still recover +it. Exhausted failures remain in the coverage notice. Cancellation propagates instead of starting +another retry. + +This is recovery within one invocation, not persisted resume across commands. Entirely failed initial +batches still use the existing whole-batch fallback. Valid empty suggestions are successful results; +responses rejected by the existing YAML parser retain its existing parse-failure behavior. +An ambiguous repeated model/deployment pair skips partial recovery rather than restarting the chain. + ## Understanding AI Code Suggestions - **AI Limitations:** AI models for code are getting better and better, but they are not flawless. Not all the suggestions will be perfect, and a user should not accept all of them automatically. Critical reading and judgment are required. Mistakes of the AI are rare but can happen, and it is usually quite easy for a human to spot them. diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index 885e32ead5..a89c882656 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -1,6 +1,7 @@ from __future__ import annotations import traceback +from contextvars import ContextVar from dataclasses import dataclass from typing import Callable, List, Tuple @@ -29,6 +30,15 @@ OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 1000 MAX_EXTRA_LINES = 10 +_effective_fallback_chain: ContextVar[tuple[tuple[str, str | None], ...] | None] = ContextVar( + "pr_agent_effective_fallback_chain", default=None +) + + +def get_effective_fallback_chain() -> tuple[tuple[str, str | None], ...] | None: + """Return the model/deployment chain selected for the current retry invocation.""" + return _effective_fallback_chain.get() + @dataclass class PreparedPRDiff: @@ -475,10 +485,14 @@ async def retry_with_fallback_models(f: Callable, model_type: ModelType = ModelT if routed: # A cheaper primary for a small pull request; config.fallback_models still follow it. all_models[0], all_deployments[0] = routed + # Ignore surplus deployment entries when fewer fallback models are configured; this matches + # the existing retry loop, which stops as soon as the first successful model returns. + effective_chain = tuple(zip(all_models, all_deployments[:len(all_models)], strict=True)) original_deployment_id = get_settings().get("openai.deployment_id", None) + context_token = _effective_fallback_chain.set(effective_chain) try: # try each (model, deployment_id) pair until one is successful, otherwise raise exception - for i, (model, deployment_id) in enumerate(zip(all_models, all_deployments, strict=True)): + for i, (model, deployment_id) in enumerate(effective_chain): try: get_logger().debug( f"Generating prediction with {model}" @@ -497,6 +511,7 @@ async def retry_with_fallback_models(f: Callable, model_type: ModelType = ModelT record_model_used(model, is_fallback=i > 0) return result finally: + _effective_fallback_chain.reset(context_token) get_settings().set("openai.deployment_id", original_deployment_id) diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 53f98eb97f..99667953f2 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -14,15 +14,17 @@ from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.git_patch_processing import decouple_and_convert_to_hunks_with_lines_numbers from pr_agent.algo.pr_processing import ( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, _get_all_models, add_ai_metadata_to_diff_files, + get_effective_fallback_chain, get_pr_diff, get_pr_multi_diffs, retry_with_fallback_models, ) from pr_agent.algo.prompt_fragments import render_diff_hunk_format from pr_agent.algo.repo_context import build_repo_context -from pr_agent.algo.run_details import init_run_details +from pr_agent.algo.run_details import init_run_details, record_model_used from pr_agent.algo.skills_loader import get_skills_context from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import ( @@ -802,13 +804,17 @@ async def _prepare_prediction(self, model: str) -> dict: data = self.prediction return data - async def _get_prediction(self, model: str, patches_diff: str, patches_diff_no_line_number: str) -> dict: + def _render_prediction_prompts(self, patches_diff: str, patches_diff_no_line_number: str) -> tuple[str, str]: variables = copy.deepcopy(self.vars) variables["diff"] = patches_diff # update diff variables["diff_no_line_numbers"] = patches_diff_no_line_number # update diff environment = Environment(undefined=StrictUndefined) system_prompt = environment.from_string(self.pr_code_suggestions_prompt_system).render(variables) user_prompt = environment.from_string(self.pr_code_suggestions_prompt_user).render(variables) + return system_prompt, user_prompt + + async def _get_prediction(self, model: str, patches_diff: str, patches_diff_no_line_number: str) -> dict: + system_prompt, user_prompt = self._render_prediction_prompts(patches_diff, patches_diff_no_line_number) response, finish_reason = await self.ai_handler.chat_completion( model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) if not get_settings().config.publish_output: @@ -1502,6 +1508,133 @@ def remove_line_numbers(self, patches_diff_list: List[str]) -> List[str]: get_logger().error(f"Error removing line numbers from patches_diff_list, error: {e}") return patches_diff_list + async def _predict_chunks(self, model: str, chunk_pairs: list) -> list: + if get_settings().pr_code_suggestions.parallel_calls: + results = await asyncio.gather( + *[self._get_prediction(model, numbered, unnumbered) for numbered, unnumbered in chunk_pairs], + return_exceptions=True, + ) + else: + results = [] + for numbered, unnumbered in chunk_pairs: + try: + results.append(await self._get_prediction(model, numbered, unnumbered)) + except Exception as error: + results.append(error) + for result in results: + if isinstance(result, BaseException) and not isinstance(result, Exception): + raise result + return results + + def _recovery_chain(self, model: str, settings) -> Optional[tuple]: + """Return the invocation-local fallback chain and current position after routing. + + The outer retry_with_fallback_models wrapper replaces the configured primary + pair with the routed one and records it as primary, so recovery must reproduce + that substitution; otherwise a routed run would search the configured chain + for a pair that is not there. Returns None when the caller's pair cannot be + found uniquely or a later pair repeats, in which case recovery gives up rather + than guess the position or retry an identical fallback. + """ + effective_chain = get_effective_fallback_chain() + if effective_chain is None: + get_logger().warning("Skipping chunk recovery: no active fallback invocation chain") + return None + original_deployment = settings.get("openai.deployment_id", None) + positions = [index for index, pair in enumerate(effective_chain) + if pair == (model, original_deployment)] + if len(positions) != 1: + get_logger().warning("Skipping chunk recovery: current model/deployment is not unique in the fallback chain") + return None + if len(set(effective_chain)) != len(effective_chain): + get_logger().warning("Skipping chunk recovery: the fallback chain repeats a model/deployment pair") + return None + return effective_chain, positions[0] + 1 + + async def _recover_failed_chunks(self, model: str, chunk_pairs: list, results: list) -> None: + """Try remaining models for failed slots, without replacing successful predictions.""" + # An entirely failed batch still belongs to the existing outer fallback loop. + if not any(isinstance(result, Exception) for result in results) or all( + isinstance(result, Exception) for result in results + ): + return + + settings = get_settings() + chain = self._recovery_chain(model, settings) + if chain is None: + return + fallback_chain, start = chain + original_deployment = settings.get("openai.deployment_id", None) + try: + for fallback_model, deployment in fallback_chain[start:]: + pending = [index for index, result in enumerate(results) if isinstance(result, Exception)] + if not pending: + break + # Keep the original diff intact; truncation must not imply complete coverage. + eligible = [] + recovered_any = False + # Resolve token controls under the same deployment that the fallback request will use. + settings.set("openai.deployment_id", deployment) + try: + token_handler = TokenHandler(model=fallback_model) + output_reserve = self._recovery_output_reserve(fallback_model) + budget = get_max_tokens(fallback_model) - output_reserve + for index in pending: + system, user = self._render_prediction_prompts(*chunk_pairs[index]) + tokens = token_handler.count_tokens(system) + token_handler.count_tokens(user) + if tokens <= budget: + eligible.append(index) + else: + get_logger().warning(f"Skipping recovery of chunk {index + 1} with {fallback_model}: " + "the complete prompt exceeds its token budget") + except Exception as error: + get_logger().warning(f"Cannot prepare chunk recovery with {fallback_model}: {error}") + continue + if not eligible: + continue + # Sibling calls have finished before the deployment switch above. + recovered = await self._predict_chunks(fallback_model, [chunk_pairs[index] for index in eligible]) + for index, result in zip(eligible, recovered, strict=True): + if isinstance(result, Exception): + get_logger().warning( + f"Failed to recover suggestion chunk {index + 1} with {fallback_model}", + artifact={"error": result}, + ) + continue + recovered_any = True + get_logger().info(f"Recovered suggestion chunk {index + 1} with {fallback_model}", + artifact={"error": results[index]}) + results[index] = result + if recovered_any: + # run_details' model line reports the outer primary unless a fallback + # ran, so mark this fallback usage stickily before it is overwritten. + record_model_used(fallback_model, is_fallback=True) + finally: + settings.set("openai.deployment_id", original_deployment) + + def _recovery_output_reserve(self, model: str) -> int: + """Return the completion headroom to reserve for a recovery fallback model. + + The LiteLLM handler reports the output allowance it will actually request + (config.max_output_tokens, extended thinking, per-provider controls); fall + back to the fixed soft threshold only when it exposes no specific limit so a + near-limit prompt is not classified as eligible without enough headroom. + """ + get_output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + if callable(get_output_token_reserve): + try: + output_tokens = get_output_token_reserve(model, OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD) + except Exception as error: + get_logger().debug(f"Failed to resolve the output token reserve for {model}: {error}") + else: + if ( + isinstance(output_tokens, int) + and not isinstance(output_tokens, bool) + and output_tokens > 0 + ): + return output_tokens + return OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD + async def prepare_prediction_main(self, model: str) -> dict: self.failed_chunk_count = 0 self.total_chunk_count = 0 @@ -1543,38 +1676,17 @@ async def prepare_prediction_main(self, model: str) -> dict: zip(self.patches_diff_list, self.patches_diff_list_no_line_numbers, strict=True)) self.total_chunk_count = len(chunk_pairs) - # parallelize calls to AI: - if get_settings().pr_code_suggestions.parallel_calls: - prediction_results = await asyncio.gather( - *[self._get_prediction(model, patches_diff, patches_diff_no_line_numbers) for - patches_diff, patches_diff_no_line_numbers in chunk_pairs], - return_exceptions=True) - for chunk_index, prediction in enumerate(prediction_results): - if isinstance(prediction, Exception): - chunk_errors.append(prediction) - get_logger().warning( - f"Failed to generate code suggestions for chunk {chunk_index + 1}; " - "retaining successful chunks", - artifact={"error": prediction}, - ) - elif isinstance(prediction, BaseException): - raise prediction - else: - prediction_list.append(prediction) - else: - for chunk_index, (patches_diff, patches_diff_no_line_numbers) in enumerate( - chunk_pairs): - try: - prediction = await self._get_prediction(model, patches_diff, patches_diff_no_line_numbers) - except Exception as e: - chunk_errors.append(e) - get_logger().warning( - f"Failed to generate code suggestions for chunk {chunk_index + 1}; " - "retaining successful chunks", - artifact={"error": e}, - ) - else: - prediction_list.append(prediction) + prediction_results = await self._predict_chunks(model, chunk_pairs) + await self._recover_failed_chunks(model, chunk_pairs, prediction_results) + for chunk_index, prediction in enumerate(prediction_results): + if isinstance(prediction, Exception): + chunk_errors.append(prediction) + get_logger().warning( + f"Failed to generate code suggestions for chunk {chunk_index + 1}; retaining successful chunks", + artifact={"error": prediction}, + ) + else: + prediction_list.append(prediction) self.failed_chunk_count = len(chunk_errors) + self.parse_failure_count if chunk_errors and not prediction_list: diff --git a/tests/unittest/test_suggestion_chunk_recovery.py b/tests/unittest/test_suggestion_chunk_recovery.py new file mode 100644 index 0000000000..b6d4f44a80 --- /dev/null +++ b/tests/unittest/test_suggestion_chunk_recovery.py @@ -0,0 +1,480 @@ +"""Behavior of recovery after a partially successful suggestion pass.""" + +import asyncio +import copy +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +import yaml +from starlette_context import request_cycle_context + +import pr_agent.tools.pr_code_suggestions as module +from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.algo.run_details import get_run_details, init_run_details +from pr_agent.algo.types import FilePatchInfo +from pr_agent.config_loader import get_settings +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +PRCodeSuggestions = module.PRCodeSuggestions + + +@pytest.fixture +def configured(): + values = { + "config.model": "gpt-4o", + "config.fallback_models": ["gpt-4o-mini", "gpt-4.1"], + "config.publish_output": True, + "openai.deployment_id": "primary", + "openai.fallback_deployments": ["secondary", "last"], + "pr_code_suggestions.decouple_hunks": True, + "pr_code_suggestions.parallel_calls": True, + "pr_code_suggestions.suggestions_score_threshold": 0, + "pr_code_suggestions.max_suggestions_per_file": 0, + # Not used by most tests here, but snapshotted so the routed-primary test's + # writes are restored and no routing state leaks into later tests. + "model_routing.enable": False, + "model_routing.rules": [], + } + snapshot = snapshot_settings(tuple(values)) + for key, value in values.items(): + get_settings().set(key, value) + yield + restore_settings(snapshot) + + +def make_tool(monkeypatch, failures): + tool = PRCodeSuggestions.__new__(PRCodeSuggestions) + tool.git_provider = MagicMock() + tool.token_handler = MagicMock() + tool.vars = {"instructions": "scope-one"} + tool.pr_code_suggestions_prompt_system = "Review {{ instructions }}" + tool.pr_code_suggestions_prompt_user = "{{ diff }}" + calls = [] + + async def completion(*, model, system, user, **kwargs): + calls.append((model, user, get_settings().get("openai.deployment_id"), system)) + failure = failures.get((model, user)) + if isinstance(failure, BaseException): + raise failure + if callable(failure): + await failure() + if isinstance(failure, str): + return failure, "stop" + suggestion = { + "relevant_file": user + ".py", "one_sentence_summary": "Check " + user, + "label": "possible issue", "suggestion_content": "Handle the error.", + "existing_code": "old()", "improved_code": "new()", + "relevant_lines_start": 1, "relevant_lines_end": 1, + } + return yaml.safe_dump({"code_suggestions": [suggestion]}), "stop" + + async def reflect(*args): + return "" + + tool.ai_handler = SimpleNamespace(chat_completion=completion) + tool._self_reflect_with_fallback = reflect + monkeypatch.setattr(module, "get_pr_multi_diffs", lambda *a, **k: ["a", "b", "c"]) + return tool, calls + + +@pytest.mark.parametrize("parallel", [True, False]) +async def test_recovery_restores_missing_results_without_replacing_successes(configured, monkeypatch, parallel): + get_settings().set("pr_code_suggestions.parallel_calls", parallel) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): TimeoutError("provider timeout")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [(m, chunk, dep) for m, chunk, dep, _ in calls] == [ + ("gpt-4o", "a", "primary"), ("gpt-4o", "b", "primary"), + ("gpt-4o", "c", "primary"), ("gpt-4o-mini", "b", "secondary"), + ] + assert tool.failed_chunk_count == 0 + assert tool.total_chunk_count == 3 + assert get_settings().get("openai.deployment_id") == "primary" + + +async def test_recovery_exhaustion_keeps_successes_and_reports_remaining_gap(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): RuntimeError("primary failed"), + ("gpt-4o", "c"): RuntimeError("primary failed"), + ("gpt-4o-mini", "b"): RuntimeError("secondary failed"), + ("gpt-4o-mini", "c"): RuntimeError("secondary failed"), + ("gpt-4.1", "c"): RuntimeError("last failed"), + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py"] + assert tool.failed_chunk_count == 1 + assert [(m, c) for m, c, _, _ in calls].count(("gpt-4o", "a")) == 1 + assert [c for m, c, _, _ in calls if m == "gpt-4.1"] == ["b", "c"] + + +async def test_healthy_run_never_enters_recovery(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + assert tool.failed_chunk_count == 0 + assert {m for m, _, _, _ in calls} == {"gpt-4o"} + + +@pytest.mark.parametrize("fallbacks, expected", [([], ["a.py", "c.py"]), (["gpt-4o-mini"], ["a.py", "b.py", "c.py"])]) +async def test_fallback_recovery_runs_whenever_fallbacks_are_set(configured, monkeypatch, fallbacks, expected): + # Recovery is the default behaviour whenever config.fallback_models is set, + # so an empty fallback list preserves the partial-success policy while a + # configured fallback repairs the failed chunk. + get_settings().set("config.fallback_models", fallbacks) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == expected + assert tool.failed_chunk_count == (1 if not fallbacks else 0) + assert len(calls) == (3 if not fallbacks else 4) + + +async def test_larger_fallback_recovers_when_earlier_one_is_over_budget(configured, monkeypatch): + # The first fallback model cannot fit the full prompt, so it is skipped and + # recovery still moves on to the later model instead of giving up. + get_settings().set("config.fallback_models", ["gpt-4o-mini", "gpt-4.1"]) + monkeypatch.setattr(module, "get_max_tokens", lambda model: 1600 if model == "gpt-4o-mini" else 10000) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.vars["instructions"] = "must retain these instructions " * 500 + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert tool.failed_chunk_count == 0 + assert [(m, c) for m, c, _, _ in calls if m == "gpt-4.1"] == [("gpt-4.1", "b")] + + +async def test_all_failed_primary_keeps_existing_outer_fallback(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {("gpt-4o", c): RuntimeError("failure") for c in "abc"}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o"] * 3 + ["gpt-4o-mini"] * 3 + assert tool.failed_chunk_count == 0 + + +async def test_partial_success_on_outer_fallback_only_tries_later_models(configured, monkeypatch): + failures = {("gpt-4o", c): RuntimeError("failure") for c in "abc"} + failures[("gpt-4o-mini", "b")] = RuntimeError("secondary failed") + tool, calls = make_tool(monkeypatch, failures) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o"] * 3 + ["gpt-4o-mini"] * 3 + ["gpt-4.1"] + assert get_settings().get("openai.deployment_id") == "primary" + + +@pytest.mark.parametrize("remaining_model", [True, False]) +async def test_oversized_fallback_is_skipped_without_truncating_context(configured, monkeypatch, remaining_model): + if not remaining_model: + get_settings().set("config.fallback_models", ["gpt-4o-mini"]) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.vars["instructions"] = "must retain these instructions " * 500 + # Use the real per-model tokenizer, with a deliberately smaller configured capacity. + monkeypatch.setattr(module, "get_max_tokens", lambda model: 1600 if model == "gpt-4o-mini" else 10000) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert not any(m == "gpt-4o-mini" for m, _, _, _ in calls) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ( + ["a.py", "b.py", "c.py"] if remaining_model else ["a.py", "c.py"] + ) + assert tool.failed_chunk_count == (0 if remaining_model else 1) + assert all(system == "Review " + tool.vars["instructions"] for _, _, _, system in calls) + + +async def test_marker_text_in_diff_is_counted_literally_for_recovery(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {("gpt-4o", "<|endoftext|>"): RuntimeError("failure")}) + # Patch after make_tool so the fixture's default chunk list does not win. + monkeypatch.setattr(module, "get_pr_multi_diffs", lambda *a, **k: ["a", "<|endoftext|>", "c"]) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert ("gpt-4o-mini", "<|endoftext|>", "secondary") in [(m, c, d) for m, c, d, _ in calls] + assert len(result["code_suggestions"]) == 3 + + +async def test_boundary_size_chunk_is_eligible_for_recovery(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + monkeypatch.setattr(module.TokenHandler, "count_tokens", lambda self, s: 1) + monkeypatch.setattr(module, "get_max_tokens", lambda model: 1502 if model == "gpt-4o-mini" else 10000) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert tool.failed_chunk_count == 0 + + +async def test_recovery_reserves_handler_output_budget_for_fallback(configured, monkeypatch): + # When the AI handler exposes a concrete output allowance (e.g. a large + # config.max_output_tokens), recovery reserves it for the completion instead of + # the fixed soft threshold. The first fallback then cannot fit the prompt + # (5001 - 5000 = 1 < 2 tokens), while the fixed 1500 threshold would have let + # it recover the chunk; only the later model recovers it. + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + reserves = [] + + def get_output_token_reserve(model, default): + reserves.append((model, get_settings().get("openai.deployment_id"))) + return 5000 + + tool.ai_handler = SimpleNamespace( + chat_completion=tool.ai_handler.chat_completion, + get_output_token_reserve=get_output_token_reserve, + ) + monkeypatch.setattr(module.TokenHandler, "count_tokens", lambda self, s: 1) + monkeypatch.setattr(module, "get_max_tokens", lambda model: 5001 if model == "gpt-4o-mini" else 10000) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert tool.failed_chunk_count == 0 + assert [m for m, _, _, _ in calls] == ["gpt-4o"] * 3 + ["gpt-4.1"] + assert [(m, c) for m, c, _, _ in calls if m == "gpt-4.1"] == [("gpt-4.1", "b")] + assert reserves == [("gpt-4o-mini", "secondary"), ("gpt-4.1", "last")] + + +async def test_empty_prediction_is_a_success_not_a_retry_trigger(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "a"): "code_suggestions: []", + ("gpt-4o", "b"): TimeoutError("failure"), + ("gpt-4o-mini", "b"): "code_suggestions: []", + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["c.py"] + assert tool.failed_chunk_count == 0 + assert len(calls) == 4 + + +async def test_invalid_yaml_recovery_preserves_parse_failure_notice(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): TimeoutError("failure"), + ("gpt-4o-mini", "b"): "not a suggestions mapping", + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "c.py"] + assert tool.failed_chunk_count == 1 + assert tool.parse_failure_count == 1 + assert len(calls) == 4 + + +@pytest.mark.parametrize("parent_cancel", [True, False]) +async def test_cancellation_stops_recovery_and_restores_deployment(configured, monkeypatch, parent_cancel): + started = asyncio.Event() + finished = asyncio.Event() + + async def wait_or_cancel(): + started.set() + try: + if parent_cancel: + await asyncio.Event().wait() + raise asyncio.CancelledError() + finally: + finished.set() + + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): RuntimeError("failure"), + ("gpt-4o-mini", "b"): wait_or_cancel, + }) + task = asyncio.create_task(retry_with_fallback_models(tool.prepare_prediction_main)) + await asyncio.wait_for(started.wait(), timeout=5) + if parent_cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert finished.is_set() + assert not any(m == "gpt-4.1" for m, _, _, _ in calls) + assert get_settings().get("openai.deployment_id") == "primary" + tool.git_provider.publish_comment.assert_not_called() + + +async def test_model_switch_waits_for_successful_sibling_to_finish(configured, monkeypatch): + completed = asyncio.Event() + + async def finish_later(): + await asyncio.sleep(0) + assert get_settings().get("openai.deployment_id") == "primary" + completed.set() + + async def recover(): + assert completed.is_set() + assert get_settings().get("openai.deployment_id") == "secondary" + + tool, _ = make_tool(monkeypatch, { + ("gpt-4o", "a"): finish_later, + ("gpt-4o", "b"): RuntimeError("failure"), + ("gpt-4o-mini", "b"): recover, + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + + +async def test_instances_do_not_reuse_predictions_or_prompt_context(configured, monkeypatch): + first, first_calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + await retry_with_fallback_models(first.prepare_prediction_main) + second, second_calls = make_tool(monkeypatch, {("gpt-4o", "a"): RuntimeError("failure")}) + second.vars["instructions"] = "scope-two" + result = await retry_with_fallback_models(second.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + assert all(system == "Review scope-one" for _, _, _, system in first_calls) + assert all(system == "Review scope-two" for _, _, _, system in second_calls) + assert [c for m, c, _, _ in second_calls if m == "gpt-4o-mini"] == ["a"] + + +async def test_concurrent_requests_keep_separate_deployments(configured, monkeypatch): + both_recovering = asyncio.Event() + started = [] + + async def invoke(name): + with request_cycle_context({"settings": copy.deepcopy(get_settings())}): + get_settings().set("openai.deployment_id", name + "-primary") + get_settings().set("openai.fallback_deployments", [name + "-secondary", name + "-last"]) + + async def overlap(): + started.append(name) + if len(started) == 2: + both_recovering.set() + await asyncio.wait_for(both_recovering.wait(), timeout=5) + assert get_settings().get("openai.deployment_id") == name + "-secondary" + + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): TimeoutError("failure"), ("gpt-4o-mini", "b"): overlap, + }) + tool.vars["instructions"] = name + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + assert all(dep.startswith(name) and system == "Review " + name for _, _, dep, system in calls) + assert get_settings().get("openai.deployment_id") == name + "-primary" + + await asyncio.gather(invoke("one"), invoke("two")) + assert get_settings().get("openai.deployment_id") == "primary" + + +@pytest.mark.parametrize("recovered", [True, False]) +async def test_published_suggestions_and_coverage_match_completed_chunks(configured, monkeypatch, recovered): + failures = {("gpt-4o", "b"): RuntimeError("primary failed")} + if not recovered: + failures.update({(model, "b"): RuntimeError("failed") for model in ["gpt-4o-mini", "gpt-4.1"]}) + tool, _ = make_tool(monkeypatch, failures) + tool.git_provider.diff_files = [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"] + tool.git_provider.publish_code_suggestions.return_value = True + tool.git_provider.supports_code_suggestions_artifact.return_value = False + init_run_details() + result = await retry_with_fallback_models(tool.prepare_prediction_main) + await tool.push_inline_code_suggestions(result) + published = tool.git_provider.publish_code_suggestions.call_args.args[0] + assert [s["relevant_file"] for s in published] == (["a.py", "b.py", "c.py"] if recovered else ["a.py", "c.py"]) + assert all("```suggestion\nnew()\n```" in s["body"] for s in published) + assert bool(tool._get_suggestions_coverage_footer()) is not recovered + assert tool.git_provider.publish_comment.called is not recovered + details = get_run_details() + assert details.fallback_used is recovered + # The outer wrapper records the primary model after recovery restores the deployment, + # so the model line names the primary while the sticky flag preserves the fallback. + assert details.model_used == "gpt-4o" + assert details.num_ai_calls == 0 + + +async def test_recovered_suggestion_still_passes_anchor_validation(configured, monkeypatch): + tool, _ = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.git_provider.diff_files = [FilePatchInfo("", "old()\n", "", c + ".py") for c in "ac"] + tool.git_provider.supports_code_suggestions_artifact.return_value = False + result = await retry_with_fallback_models(tool.prepare_prediction_main) + await tool.push_inline_code_suggestions(result) + published = tool.git_provider.publish_code_suggestions.call_args.args[0] + assert [s["relevant_file"] for s in published] == ["a.py", "c.py"] + assert "b.py" in tool.git_provider.publish_comment.call_args.args[0] + assert "```suggestion" not in tool.git_provider.publish_comment.call_args.args[0] + + +async def test_recovered_run_details_report_primary_model_and_sticky_fallback(configured, monkeypatch): + tool, _ = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.git_provider.diff_files = [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"] + tool.git_provider.publish_code_suggestions.return_value = True + tool.git_provider.supports_code_suggestions_artifact.return_value = False + init_run_details() + result = await retry_with_fallback_models(tool.prepare_prediction_main) + await tool.push_inline_code_suggestions(result) + details = get_run_details() + assert details.fallback_used is True + assert details.model_used == "gpt-4o" + assert details.num_ai_calls == 0 + + +async def test_ambiguous_model_deployment_chain_keeps_partial_result(configured, monkeypatch): + get_settings().set("config.fallback_models", ["gpt-4o", "gpt-4.1"]) + get_settings().set("openai.fallback_deployments", ["primary", "last"]) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "c.py"] + assert len(calls) == 3 + + +async def test_repeated_fallback_pair_skips_recoveries_without_retrying_it(configured, monkeypatch): + # A unique fallback pair appearing twice in the chain would route the same + # failed chunk to an identical (model, deployment) a second time, so recovery + # gives up and keeps the partial result instead of adding duplicate inference. + get_settings().set("config.fallback_models", ["gpt-4o-mini", "gpt-4o-mini"]) + get_settings().set("openai.fallback_deployments", ["secondary", "secondary"]) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "c.py"] + assert len(calls) == 3 + assert tool.failed_chunk_count == 1 + + +async def test_recovery_uses_existing_reflection_before_publishing_results(configured, monkeypatch): + keys = ("config.model_reasoning", "pr_code_suggestions_reflect_prompt.system", + "pr_code_suggestions_reflect_prompt.user") + snapshot = snapshot_settings(keys) + try: + get_settings().set(keys[0], "gpt-4o") + get_settings().set(keys[1], "Reflect") + get_settings().set(keys[2], "{{ diff }}") + tool, _ = make_tool(monkeypatch, {("gpt-4o", "b"): TimeoutError("failure")}) + del tool._self_reflect_with_fallback + generation = tool.ai_handler.chat_completion + reflections = [] + + async def completion(*, model, system, user, **kwargs): + if system != "Reflect": + return await generation(model=model, system=system, user=user, **kwargs) + reflections.append((model, user, get_settings().get("openai.deployment_id"))) + return yaml.safe_dump({"code_suggestions": [{"suggestion_score": 9, "why": "Verified"}]}), "stop" + + tool.ai_handler.chat_completion = completion + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert all(s["score"] == 9 and s["score_why"] == "Verified" for s in result["code_suggestions"]) + assert ("gpt-4o-mini", "b", "secondary") in reflections + assert len(reflections) == 3 + finally: + restore_settings(snapshot) + + +async def test_routed_primary_recovers_failed_slots_with_configured_fallback(configured, monkeypatch): + # A routed primary ([model_routing]) replaces config.model ahead of the same + # fallbacks; recovery must reproduce that substitution instead of scanning + # the configured chain. The routed model is kept out of the fallback list so + # the (model, deployment) pair stays unique in the effective chain. + get_settings().set("config.fallback_models", ["gpt-4.1"]) + get_settings().set("openai.fallback_deployments", ["last"]) + get_settings().set("model_routing.enable", True) + get_settings().set("model_routing.rules", + [{"model": "gpt-4o-mini", "max_files": 10, "deployment_id": "secondary"}]) + tool, calls = make_tool(monkeypatch, {("gpt-4o-mini", "b"): RuntimeError("failure")}) + tool.git_provider.get_diff_files.return_value = [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"] + result = await retry_with_fallback_models(tool.prepare_prediction_main, + git_provider=tool.git_provider) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o-mini"] * 3 + ["gpt-4.1"] + assert get_settings().get("openai.deployment_id") == "primary" + tool.git_provider.get_diff_files.assert_called_once_with() + + +async def test_routing_is_not_repeated_during_recovery(configured, monkeypatch): + get_settings().set("config.fallback_models", ["gpt-4.1"]) + get_settings().set("openai.fallback_deployments", ["last"]) + get_settings().set("model_routing.enable", True) + get_settings().set("model_routing.rules", + [{"model": "gpt-4o-mini", "max_files": 10, "deployment_id": "secondary"}]) + tool, calls = make_tool(monkeypatch, {("gpt-4o-mini", "b"): RuntimeError("failure")}) + tool.git_provider.get_diff_files.side_effect = [ + [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"], + RuntimeError("transient routing failure"), + ] + + result = await retry_with_fallback_models(tool.prepare_prediction_main, + git_provider=tool.git_provider) + + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o-mini"] * 3 + ["gpt-4.1"] + tool.git_provider.get_diff_files.assert_called_once_with() From e9807885dc987cf475034943e18701555cd2e3f5 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Mon, 14 Sep 2026 03:00:50 -0500 Subject: [PATCH 089/238] fix: load repo settings before push automation (#3347) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> Co-authored-by: IsmaelMartinez --- pr_agent/servers/bitbucket_app.py | 7 +- pr_agent/servers/gitea_app.py | 7 +- tests/unittest/test_pr_command_profiles.py | 113 ++++++++++++++ tests/unittest/test_webhook_logic_core.py | 165 +++++++++++++++++++++ 4 files changed, 286 insertions(+), 6 deletions(-) diff --git a/pr_agent/servers/bitbucket_app.py b/pr_agent/servers/bitbucket_app.py index e4bb0e49a5..f51e0b5adb 100644 --- a/pr_agent/servers/bitbucket_app.py +++ b/pr_agent/servers/bitbucket_app.py @@ -196,6 +196,9 @@ async def _perform_commands_bitbucket(commands_conf: str, agent: PRAgent, api_ur if commands_conf == "pr_commands" else get_settings().get(f"bitbucket_app.{commands_conf}", {}) ) + if not commands: + get_logger().info(f"No {commands_conf} configured, skipping auto commands") + return get_settings().set("config.is_auto_command", True) if commands_conf == "push_commands": is_valid_push = await _validate_time_from_last_commit_to_pr_update(data) @@ -391,9 +394,7 @@ async def inner(): with get_logger().contextualize(**log_context): if get_identity_provider().verify_eligibility("bitbucket", sender_id, pr_url) is not Eligibility.NOT_ELIGIBLE: - - if get_settings().get("bitbucket_app.push_commands"): - await _perform_commands_bitbucket("push_commands", agent, pr_url, log_context, data) + await _perform_commands_bitbucket("push_commands", agent, pr_url, log_context, data) elif event == "pullrequest:comment_created": pr_url = data["data"]["pullrequest"]["links"]["html"]["href"] log_context["api_url"] = pr_url diff --git a/pr_agent/servers/gitea_app.py b/pr_agent/servers/gitea_app.py index 970538fb06..aa134c7b73 100644 --- a/pr_agent/servers/gitea_app.py +++ b/pr_agent/servers/gitea_app.py @@ -93,6 +93,10 @@ async def handle_pr_event(body: Dict[str, Any], event: str, action: str, agent: if not api_url: return + apply_repo_settings(api_url) + if not should_process_pr_logic(body): + return {} + # Handle PR based on action if action in ["opened", "reopened"]: # commands = get_settings().get("gitea.pr_commands", []) @@ -130,12 +134,9 @@ async def handle_comment_event(body: Dict[str, Any], event: str, action: str, ag await agent.handle_request(pr_url, comment_body) async def _perform_commands_gitea(commands_conf: str, agent: PRAgent, body: dict, api_url: str): - apply_repo_settings(api_url) if commands_conf == "pr_commands" and get_settings().config.disable_auto_feedback: # auto commands for PR, and auto feedback is disabled get_logger().info(f"Auto feedback is disabled, skipping auto commands for PR {api_url=}") return - if not should_process_pr_logic(body): # Here we already updated the configuration with the repo settings - return {} commands = ( get_pr_commands("gitea") if commands_conf == "pr_commands" diff --git a/tests/unittest/test_pr_command_profiles.py b/tests/unittest/test_pr_command_profiles.py index 1b01003897..0f30313fed 100644 --- a/tests/unittest/test_pr_command_profiles.py +++ b/tests/unittest/test_pr_command_profiles.py @@ -282,6 +282,119 @@ async def perform_commands(*args): assert calls[0][0] == "pr_commands" +async def _run_bitbucket_push_webhook( + monkeypatch, + *, + host_trigger, + host_commands, + repo_trigger, + repo_commands, +): + calls = [] + pr_url = "https://example.test/pr/1" + payload = { + "event": "pullrequest:updated", + "data": { + "actor": {"type": "user", "account_id": "account"}, + "pullrequest": {"links": {"html": {"href": pr_url}}}, + }, + } + secret_provider = type( + "SecretProvider", + (), + {"get_secret": lambda self, _key: json.dumps({"shared_secret": "secret"})}, + )() + + async def get_bearer_token(_shared_secret, _client_key): + return "bearer" + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", repo_trigger) + get_settings().set("BITBUCKET_APP.PUSH_COMMANDS", list(repo_commands)) + + async def validate_push(_data): + calls.append("validate") + return True + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + async def run_commands(commands, *_args): + calls.append(("commands", list(commands))) + + monkeypatch.setattr(bitbucket_app, "is_bot_user", lambda _data: False) + monkeypatch.setattr(bitbucket_app, "get_fork_safe_secret_provider", lambda: secret_provider) + monkeypatch.setattr(bitbucket_app, "get_bearer_token", get_bearer_token) + monkeypatch.setattr(bitbucket_app.jwt, "decode", lambda *args, **kwargs: {}) + monkeypatch.setattr(bitbucket_app, "get_identity_provider", _IdentityProvider) + monkeypatch.setattr(bitbucket_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr( + bitbucket_app, + "should_process_pr_logic", + lambda _data: pytest.fail("Bitbucket update events must not use PR creation filters"), + ) + monkeypatch.setattr(bitbucket_app, "_validate_time_from_last_commit_to_pr_update", validate_push) + monkeypatch.setattr(bitbucket_app, "push_trigger_slot", record_slot) + monkeypatch.setattr(bitbucket_app, "_run_commands_bitbucket", run_commands) + endpoint = next(route.endpoint for route in bitbucket_app.router.routes if route.path == "/webhook") + background_tasks = BackgroundTasks() + original_push_commands = global_settings.get("BITBUCKET_APP.PUSH_COMMANDS", None) + original_handle_push = global_settings.get("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", None) + original_base_url = global_settings.get("BITBUCKET.BASE_URL", None) + global_settings.set("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", host_trigger) + global_settings.set("BITBUCKET_APP.PUSH_COMMANDS", list(host_commands)) + global_settings.set("BITBUCKET.BASE_URL", "https://example.test/app") + + try: + with request_cycle_context({}): + result = await endpoint(background_tasks, _Request(payload)) + await background_tasks() + finally: + global_settings.set("BITBUCKET_APP.PUSH_COMMANDS", original_push_commands) + global_settings.set("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", original_handle_push) + global_settings.set("BITBUCKET.BASE_URL", original_base_url) + + return result, calls + + +async def test_bitbucket_app_push_applies_repo_settings_before_effective_gate(monkeypatch): + result, calls = await _run_bitbucket_push_webhook( + monkeypatch, + host_trigger=False, + host_commands=[], + repo_trigger=True, + repo_commands=["/review"], + ) + + assert result == "OK" + assert calls == ["settings", "validate", "slot", ("commands", ["/review"])] + + +@pytest.mark.parametrize( + ("repo_trigger", "repo_commands"), + [ + (False, ["/review"]), + (True, []), + ], +) +async def test_bitbucket_app_effective_push_config_skips_before_validation( + monkeypatch, repo_trigger, repo_commands +): + result, calls = await _run_bitbucket_push_webhook( + monkeypatch, + host_trigger=True, + host_commands=["/host-review"], + repo_trigger=repo_trigger, + repo_commands=repo_commands, + ) + + assert result == "OK" + assert calls == ["settings"] + + @pytest.mark.parametrize("proceed", [True, False]) async def test_bitbucket_app_push_uses_shared_dedupe_slot(monkeypatch, proceed): calls = [] diff --git a/tests/unittest/test_webhook_logic_core.py b/tests/unittest/test_webhook_logic_core.py index 1e67514a14..2f1cf34341 100644 --- a/tests/unittest/test_webhook_logic_core.py +++ b/tests/unittest/test_webhook_logic_core.py @@ -722,6 +722,8 @@ async def reject_duplicate(key, **kwargs): async def perform_commands(*args): performed.append(args) + monkeypatch.setattr(gitea_app, "apply_repo_settings", lambda _url: None) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", lambda _body: True) monkeypatch.setattr(gitea_app, "push_trigger_slot", reject_duplicate) monkeypatch.setattr(gitea_app, "_perform_commands_gitea", perform_commands) api_url = "https://gitea.example.com/org/repo/pulls/1" @@ -736,6 +738,169 @@ async def perform_commands(*args): assert slots == [(api_url, {"allow_backlog": True, "ttl": 300})] +@pytest.mark.asyncio +async def test_gitea_push_applies_repo_settings_before_effective_gate(monkeypatch): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + original_is_auto_command = settings.get("CONFIG.IS_AUTO_COMMAND") + settings.set("GITEA.HANDLE_PUSH_TRIGGER", False) + settings.set("GITEA.PUSH_COMMANDS", []) + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("GITEA.HANDLE_PUSH_TRIGGER", True) + get_settings().set("GITEA.PUSH_COMMANDS", ["/review"]) + + def should_process_pr_logic(_body): + calls.append("filter") + return True + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + class Agent: + async def handle_request(self, _url, command): + calls.append(command) + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "push_trigger_slot", record_slot) + monkeypatch.setattr(gitea_app, "prepare_command", lambda command: command) + api_url = "https://gitea.example.com/org/repo/pulls/1" + body = { + "pull_request": {"url": api_url}, + "repository": {"full_name": "org/repo"}, + } + + try: + await gitea_app.handle_pr_event(body, "pull_request", "synchronized", Agent()) + finally: + settings.set("GITEA", original_gitea) + settings.set("CONFIG.IS_AUTO_COMMAND", original_is_auto_command) + + assert calls == ["settings", "filter", "slot", "/review"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("repo_trigger", "repo_commands"), + [ + (False, ["/review"]), + (True, []), + ], +) +async def test_gitea_effective_push_config_skips_before_reserving_slot( + monkeypatch, repo_trigger, repo_commands +): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + settings.set("GITEA.HANDLE_PUSH_TRIGGER", True) + settings.set("GITEA.PUSH_COMMANDS", ["/host-review"]) + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("GITEA.HANDLE_PUSH_TRIGGER", repo_trigger) + get_settings().set("GITEA.PUSH_COMMANDS", list(repo_commands)) + + def should_process_pr_logic(_body): + calls.append("filter") + return True + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "push_trigger_slot", record_slot) + api_url = "https://gitea.example.com/org/repo/pulls/1" + + try: + await gitea_app.handle_pr_event( + {"pull_request": {"url": api_url}}, "pull_request", "synchronized", RecordingAgent() + ) + finally: + settings.set("GITEA", original_gitea) + + assert calls == ["settings", "filter"] + + +@pytest.mark.asyncio +async def test_gitea_repo_filter_rejects_push_before_reserving_slot(monkeypatch): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + settings.set("GITEA.HANDLE_PUSH_TRIGGER", True) + settings.set("GITEA.PUSH_COMMANDS", ["/review"]) + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + + def should_process_pr_logic(_body): + calls.append("filter") + return False + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "push_trigger_slot", record_slot) + api_url = "https://gitea.example.com/org/repo/pulls/1" + + try: + await gitea_app.handle_pr_event( + {"pull_request": {"url": api_url}}, "pull_request", "synchronized", RecordingAgent() + ) + finally: + settings.set("GITEA", original_gitea) + + assert calls == ["settings", "filter"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["opened", "reopened"]) +async def test_gitea_pr_event_applies_repo_settings_once_before_dispatch(monkeypatch, action): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + original_is_auto_command = settings.get("CONFIG.IS_AUTO_COMMAND") + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("GITEA.PR_COMMANDS", ["/review"]) + + def should_process_pr_logic(_body): + calls.append("filter") + return True + + class Agent: + async def handle_request(self, _url, command): + calls.append(command) + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "prepare_command", lambda command: command) + api_url = "https://gitea.example.com/org/repo/pulls/1" + + try: + await gitea_app.handle_pr_event( + {"pull_request": {"url": api_url}}, "pull_request", action, Agent() + ) + finally: + settings.set("GITEA", original_gitea) + settings.set("CONFIG.IS_AUTO_COMMAND", original_is_auto_command) + + assert calls == ["settings", "filter", "/review"] + + @pytest.mark.asyncio @pytest.mark.parametrize( ("event", "draft", "feedback_on_draft_pr", "expected_commands"), From c41775bdd1335e391b1e450e67190ad456837c03 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Mon, 14 Sep 2026 03:01:07 -0500 Subject: [PATCH 090/238] fix(bitbucket): load repo PR commands before dispatch (#3350) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> Co-authored-by: IsmaelMartinez --- pr_agent/servers/bitbucket_app.py | 3 +- tests/unittest/test_pr_command_profiles.py | 134 ++++++++++++++++++--- 2 files changed, 116 insertions(+), 21 deletions(-) diff --git a/pr_agent/servers/bitbucket_app.py b/pr_agent/servers/bitbucket_app.py index f51e0b5adb..056cf79a37 100644 --- a/pr_agent/servers/bitbucket_app.py +++ b/pr_agent/servers/bitbucket_app.py @@ -384,8 +384,7 @@ async def inner(): with get_logger().contextualize(**log_context): if get_identity_provider().verify_eligibility("bitbucket", sender_id, pr_url) is not Eligibility.NOT_ELIGIBLE: - if get_pr_commands("bitbucket_app"): - await _perform_commands_bitbucket("pr_commands", agent, pr_url, log_context, data) + await _perform_commands_bitbucket("pr_commands", agent, pr_url, log_context, data) elif event == "pullrequest:updated": # PR updated, might be from a push (we will validate this later) pr_url = data["data"]["pullrequest"]["links"]["html"]["href"] log_context["api_url"] = pr_url diff --git a/tests/unittest/test_pr_command_profiles.py b/tests/unittest/test_pr_command_profiles.py index 0f30313fed..e375ae350a 100644 --- a/tests/unittest/test_pr_command_profiles.py +++ b/tests/unittest/test_pr_command_profiles.py @@ -235,8 +235,16 @@ async def test_each_webhook_dispatches_its_default_profile(provider, monkeypatch assert agent.commands == DEFAULT_PR_COMMANDS[provider] -async def test_bitbucket_app_default_profile_passes_the_early_gate(monkeypatch): - calls = [] +async def _run_bitbucket_created_webhook( + monkeypatch, + *, + host_commands, + repo_commands, + eligibility=Eligibility.ELIGIBLE, + repo_should_process=True, + disable_auto_feedback=False, +): + trace = [] payload = { "event": "pullrequest:created", "data": { @@ -253,33 +261,121 @@ async def test_bitbucket_app_default_profile_passes_the_early_gate(monkeypatch): async def get_bearer_token(_shared_secret, _client_key): return "bearer" - async def perform_commands(*args): - calls.append(args) + class RecordingIdentityProvider: + def verify_eligibility(self, *_args): + trace.append("eligibility") + return eligibility + + def should_process_pr_logic(_data): + phase = "repo_filter" if "repo_settings" in trace else "host_filter" + trace.append(phase) + return repo_should_process if phase == "repo_filter" else True + + def apply_repo_settings(_url): + trace.append("repo_settings") + get_settings().set("BITBUCKET_APP.PR_COMMANDS", copy.deepcopy(repo_commands)) + get_settings().set("CONFIG.DISABLE_AUTO_FEEDBACK", disable_auto_feedback) + + async def run_commands(commands, *_args): + trace.append(("dispatch", commands)) - monkeypatch.setattr(server_utils, "get_settings", _Settings) monkeypatch.setattr(bitbucket_app, "is_bot_user", lambda _data: False) - monkeypatch.setattr(bitbucket_app, "should_process_pr_logic", lambda _data: True) + monkeypatch.setattr(bitbucket_app, "should_process_pr_logic", should_process_pr_logic) monkeypatch.setattr(bitbucket_app, "get_fork_safe_secret_provider", lambda: secret_provider) monkeypatch.setattr(bitbucket_app, "get_bearer_token", get_bearer_token) monkeypatch.setattr(bitbucket_app.jwt, "decode", lambda *args, **kwargs: {}) - monkeypatch.setattr( - bitbucket_app, - "get_identity_provider", - _IdentityProvider, - ) - monkeypatch.setattr(bitbucket_app, "_perform_commands_bitbucket", perform_commands) + monkeypatch.setattr(bitbucket_app, "get_identity_provider", RecordingIdentityProvider) + monkeypatch.setattr(bitbucket_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(bitbucket_app, "_run_commands_bitbucket", run_commands) endpoint = next(route.endpoint for route in bitbucket_app.router.routes if route.path == "/webhook") background_tasks = BackgroundTasks() + original_bitbucket_app = copy.deepcopy(global_settings.bitbucket_app) + original_bitbucket = copy.deepcopy(global_settings.get("BITBUCKET", {})) + original_config = copy.deepcopy(global_settings.config) + global_settings.set("BITBUCKET_APP.PR_COMMANDS", copy.deepcopy(host_commands)) + global_settings.set("BITBUCKET.BASE_URL", "https://example.test/app") + global_settings.set("CONFIG.IS_AUTO_COMMAND", False) - with request_cycle_context({}): - context["settings"] = copy.deepcopy(global_settings) - get_settings().set("BITBUCKET.BASE_URL", "https://example.test/app") - result = await endpoint(background_tasks, _Request(payload)) - await background_tasks() + try: + with request_cycle_context({}): + result = await endpoint(background_tasks, _Request(payload)) + await background_tasks() + is_auto_command = get_settings().config.is_auto_command + finally: + global_settings.set("BITBUCKET_APP", original_bitbucket_app) + global_settings.set("BITBUCKET", original_bitbucket) + global_settings.set("CONFIG", original_config) + + return result, trace, is_auto_command + + +async def test_bitbucket_app_created_event_loads_repo_commands_before_dispatch(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=[], + repo_commands=["/review"], + ) + + assert result == "OK" + assert trace == [ + "host_filter", + "eligibility", + "repo_settings", + "repo_filter", + ("dispatch", ["/review"]), + ] + assert is_auto_command is True + + +async def test_bitbucket_app_created_event_skips_empty_repo_commands(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=["/review"], + repo_commands=[], + ) + + assert result == "OK" + assert trace == ["host_filter", "eligibility", "repo_settings", "repo_filter"] + assert is_auto_command is False + + +async def test_bitbucket_app_created_event_applies_repo_filter_before_dispatch(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=["/review"], + repo_commands=["/review"], + repo_should_process=False, + ) + + assert result == "OK" + assert trace == ["host_filter", "eligibility", "repo_settings", "repo_filter"] + assert is_auto_command is False + + +async def test_bitbucket_app_created_event_honors_repo_disable_auto_feedback(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=["/review"], + repo_commands=["/review"], + disable_auto_feedback=True, + ) + + assert result == "OK" + assert trace == ["host_filter", "eligibility", "repo_settings"] + assert is_auto_command is False + + +async def test_bitbucket_app_created_event_does_not_load_repo_settings_when_ineligible(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=[], + repo_commands=["/review"], + eligibility=Eligibility.NOT_ELIGIBLE, + ) assert result == "OK" - assert len(calls) == 1 - assert calls[0][0] == "pr_commands" + assert trace == ["host_filter", "eligibility"] + assert is_auto_command is False async def _run_bitbucket_push_webhook( From dabb8ce9e8f1775e39025e3de31088feabfc7152 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Mon, 14 Sep 2026 03:02:11 -0500 Subject: [PATCH 091/238] fix(help_docs): isolate malformed model rows (#3365) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/tools/pr_help_docs.py | 68 +++++-- .../test_pr_help_docs_row_isolation.py | 173 ++++++++++++++++++ 2 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 tests/unittest/test_pr_help_docs_row_isolation.py diff --git a/pr_agent/tools/pr_help_docs.py b/pr_agent/tools/pr_help_docs.py index 04b9c728d7..0d30a748da 100644 --- a/pr_agent/tools/pr_help_docs.py +++ b/pr_agent/tools/pr_help_docs.py @@ -68,6 +68,9 @@ def extract_model_answer_and_relevant_sources(ai_response: str) -> str | None: get_logger().info(f"Found model answer: {model_answer_section_in_response}") return model_answer_and_relevant_sources_sections_in_response \ if len(model_answer_section_in_response) > 0 else None + if model_answer_and_relevant_sources_sections_in_response.strip(): + get_logger().info(f"Found model answer without relevant sources: {model_answer_and_relevant_sources_sections_in_response}") + return model_answer_and_relevant_sources_sections_in_response get_logger().warning(f"Either no answer section found, or that section is malformed: {ai_response}") return None @@ -168,23 +171,39 @@ def aggregate_documentation_files_for_prompt_contents(file_path_to_contents: dic def format_markdown_q_and_a_response(question_str: str, response_str: str, relevant_sections: list[dict[str, str]], supported_suffixes: list[str], base_url_prefix: str, base_url_suffix: str="") -> str: try: - base_url_prefix = base_url_prefix.strip('/') #Sanitize base_url_prefix + base_url_prefix = base_url_prefix.strip('/') # Sanitize base_url_prefix answer_str = "" answer_str += f"### Question: \n{question_str}\n\n" answer_str += f"### Answer:\n{response_str.strip()}\n\n" - answer_str += "#### Relevant Sources:\n\n" + source_links = [] + if not isinstance(relevant_sections, list): + get_logger().warning("Skipping malformed relevant source collection: expected a list") + relevant_sections = [] for section in relevant_sections: - file = section.get('file_name').lstrip('/').strip() #Remove any '/' in the beginning, since some models do it anyway + try: + if not isinstance(section, dict): + raise ValueError("source row is not a mapping") + file_name = section.get('file_name') + header = section.get('relevant_section_header_string') + if not isinstance(file_name, str) or not isinstance(header, str): + raise ValueError("source row has an invalid file name or section header") + except ValueError as e: + get_logger().warning(f"Skipping malformed relevant source row: {e}") + continue + file = file_name.lstrip('/').strip() # Remove any leading '/', since some models add one ext = [suffix for suffix in supported_suffixes if file.endswith(suffix)] if not ext: get_logger().warning(f"Unsupported file extension: {file}") continue - if str(section['relevant_section_header_string']).strip(): - markdown_header = format_markdown_header(section['relevant_section_header_string']) + if header.strip(): + markdown_header = format_markdown_header(header) if base_url_prefix: - answer_str += f"> - {base_url_prefix}/{file}{base_url_suffix}#{markdown_header}\n" + source_links.append(f"> - {base_url_prefix}/{file}{base_url_suffix}#{markdown_header}\n") else: - answer_str += f"> - {base_url_prefix}/{file}{base_url_suffix}\n" + source_links.append(f"> - {base_url_prefix}/{file}{base_url_suffix}\n") + if source_links: + answer_str += "#### Relevant Sources:\n\n" + answer_str += ''.join(source_links) return answer_str except Exception as e: get_logger().exception("Unexpected exception thrown. Returning empty result.") @@ -217,6 +236,29 @@ def format_markdown_header(header: str) -> str: get_logger().exception("Error while formatting markdown header", artifacts={'header': header}) return "" + +def get_valid_ranking_indices(ranking: list[dict], number_of_documents: int) -> list[int]: + """Return in-range document indices while isolating malformed ranking rows.""" + valid_indices = [] + if not isinstance(ranking, list): + get_logger().warning("Skipping malformed relevant file ranking collection: expected a list") + return valid_indices + for entry in ranking: + try: + if not isinstance(entry, dict): + raise ValueError("ranking row is not a mapping") + raw_index = entry.get('idx') + if isinstance(raw_index, bool) or not isinstance(raw_index, (int, str)): + raise ValueError("ranking index has an invalid type") + index = int(raw_index) + if 0 <= index < number_of_documents: + valid_indices.append(index) + else: + raise ValueError("ranking index is out of range") + except (TypeError, ValueError) as e: + get_logger().warning(f"Skipping malformed relevant file ranking row: {e}") + return valid_indices + def clean_markdown_content(content: str) -> str: """ Remove hidden comments and unnecessary elements from markdown content to reduce size. @@ -370,9 +412,11 @@ async def run(self): return response_str = response_yaml.get('response') relevant_sections = response_yaml.get('relevant_sections') - if not response_str or not relevant_sections: - get_logger().error("Failed to extract response/relevant sections.", - artifacts={'raw_response': response, 'response_str': response_str, 'relevant_sections': relevant_sections}) + if not response_str: + get_logger().error( + "Failed to extract response.", + artifacts={'raw_response': response, 'response_str': response_str, + 'relevant_sections': relevant_sections}) return if int(response_yaml.get('question_is_relevant', '1')) == 0: get_logger().warning("Question is not relevant. Returning without an answer...", @@ -511,8 +555,8 @@ async def _rank_docs_and_return_them_as_prompt(self, docs_filepath_to_contents: get_logger().error("Failed to parse the AI response.", artifacts={'response': response}) return "" # else: Sanitize the output so that the file names match 1:1 dictionary keys. Do this via the file index and not its name, which may be altered by the model. - valid_indices = [int(entry['idx']) for entry in response_yaml.get('relevant_files_ranking') - if int(entry['idx']) >= 0 and int(entry['idx']) < len(docs_filepath_to_contents)] + valid_indices = get_valid_ranking_indices(response_yaml.get('relevant_files_ranking'), + len(docs_filepath_to_contents)) valid_file_paths = [list(docs_filepath_to_contents.keys())[idx] for idx in valid_indices] selected_docs_dict = {file_path: docs_filepath_to_contents[file_path] for file_path in valid_file_paths} docs_prompt = aggregate_documentation_files_for_prompt_contents(selected_docs_dict) diff --git a/tests/unittest/test_pr_help_docs_row_isolation.py b/tests/unittest/test_pr_help_docs_row_isolation.py new file mode 100644 index 0000000000..4073382a76 --- /dev/null +++ b/tests/unittest/test_pr_help_docs_row_isolation.py @@ -0,0 +1,173 @@ +"""Regression tests for malformed model-produced /help_docs collections.""" + +import pytest + +from pr_agent.tools.pr_help_docs import ( + PRHelpDocs, + format_markdown_q_and_a_response, + get_valid_ranking_indices, + modify_answer_section, +) + + +class StubProvider: + def get_canonical_url_parts(self, **kwargs): + return "https://example.com/repo/blob/main", "" + + +def test_format_markdown_q_and_a_response_preserves_valid_sources_when_one_row_is_malformed(): + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + [ + {"file_name": "/docs/guide.md", "relevant_section_header_string": "Getting started"}, + {"file_name": "/docs/broken.md"}, + ], + [".md"], + "https://example.com/repo/blob/main", + ) + + assert "The answer is here." in answer + assert "https://example.com/repo/blob/main/docs/guide.md#getting-started" in answer + assert "broken.md" not in answer + + +def test_format_markdown_q_and_a_response_omits_sources_when_all_rows_are_malformed(): + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + [{"file_name": "/docs/broken.md"}, None, {"relevant_section_header_string": "Missing file"}], + [".md"], + "https://example.com/repo/blob/main", + ) + + assert answer == "### Question: \nWhere is the guide?\n\n### Answer:\nThe answer is here.\n\n" + assert "Relevant Sources" not in answer + + +def test_format_markdown_q_and_a_response_omits_malformed_source_collection(): + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + {"file_name": "/docs/guide.md", "relevant_section_header_string": "Getting started"}, + [".md"], + "https://example.com/repo/blob/main", + ) + + assert answer == "### Question: \nWhere is the guide?\n\n### Answer:\nThe answer is here.\n\n" + + +def test_format_markdown_q_and_a_response_routes_unexpected_formatter_errors_to_failure_handler(monkeypatch): + def fail_to_format_header(header): + raise RuntimeError("unexpected formatter failure") + + monkeypatch.setattr("pr_agent.tools.pr_help_docs.format_markdown_header", fail_to_format_header) + + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + [{"file_name": "/docs/guide.md", "relevant_section_header_string": "Getting started"}], + [".md"], + "https://example.com/repo/blob/main", + ) + + assert answer == "" + + +def test_modify_answer_section_preserves_answer_without_sources(): + assert modify_answer_section("### Answer:\nThe answer is here.\n\n") == ( + "### :bulb: Auto-generated documentation-based answer:\nThe answer is here.\n\n" + ) + + +def test_format_model_answer_preserves_answer_when_no_sources_survive(): + tool = PRHelpDocs.__new__(PRHelpDocs) + tool.question = "Where is the guide?" + tool.supported_doc_exts = [".md"] + tool.return_as_string = True + tool.repo_url = "https://example.com/org/repo" + tool.repo_url_given_explicitly = True + tool.repo_desired_branch = "main" + tool.git_provider = StubProvider() + + assert tool._format_model_answer("The answer is here.", [{"file_name": "/docs/broken.md"}]) == ( + "### :bulb: Auto-generated documentation-based answer:\nThe answer is here.\n\n" + ) + + +@pytest.mark.asyncio +async def test_run_preserves_answer_when_model_returns_empty_source_list(monkeypatch): + async def fake_retry_with_fallback_models(*args, **kwargs): + return """question_is_relevant: 1\nresponse: The answer is here.\nrelevant_sections: []\n""" + + monkeypatch.setattr("pr_agent.tools.pr_help_docs.retry_with_fallback_models", fake_retry_with_fallback_models) + + tool = PRHelpDocs.__new__(PRHelpDocs) + tool.question = "Where is the guide?" + tool.return_as_string = True + tool.repo_url = "https://example.com/org/repo" + tool.repo_url_given_explicitly = True + tool.repo_desired_branch = "main" + tool.supported_doc_exts = [".md"] + tool.git_provider = StubProvider() + tool.ai_handler = object() + tool.vars = {"question": tool.question, "snippets": ""} + tool._gen_filenames_to_contents_map_from_repo = lambda: {"/docs/guide.md": "# Guide\n\nGuide content."} + tool._trim_docs_input = lambda docs_input, *args, only_return_if_trim_needed=False, **kwargs: ( + False if only_return_if_trim_needed else docs_input + ) + + assert await tool.run() == ( + "### :bulb: Auto-generated documentation-based answer:\nThe answer is here.\n\n" + ) + + +def test_get_valid_ranking_indices_preserves_valid_order_and_skips_malformed_rows(): + assert get_valid_ranking_indices( + [ + {"idx": "2"}, + {"idx": True}, + {"idx": "not-a-number"}, + {"idx": 0}, + {"idx": 2}, + {"idx": 4}, + {"idx": 1.0}, + "not-a-row", + ], + 3, + ) == [2, 0, 2] + + +def test_get_valid_ranking_indices_skips_non_list_collections(): + assert get_valid_ranking_indices({"idx": 0}, 1) == [] + assert get_valid_ranking_indices(True, 1) == [] + + +def test_get_valid_ranking_indices_skips_all_malformed_rows(): + assert get_valid_ranking_indices( + [{"idx": "not-a-number"}, {"idx": -1}, {"idx": 2}, None], + 2, + ) == [] + + +@pytest.mark.asyncio +async def test_rank_docs_preserves_valid_rows_when_model_ranking_is_partially_malformed(monkeypatch): + async def fake_retry_with_fallback_models(*args, **kwargs): + return """relevant_files_ranking:\n - idx: 2\n - invalid: row\n - idx: 0\n""" + + monkeypatch.setattr("pr_agent.tools.pr_help_docs.retry_with_fallback_models", fake_retry_with_fallback_models) + + tool = PRHelpDocs.__new__(PRHelpDocs) + tool.ai_handler = object() + tool.vars = {"question": "Where is the guide?", "snippets": ""} + tool._trim_docs_input = lambda docs_input, *args, **kwargs: docs_input + docs = { + "/docs/first.md": "# First\n\nFirst document.", + "/docs/second.md": "# Second\n\nSecond document.", + "/docs/third.md": "# Third\n\nThird document.", + } + + ranked_prompt = await tool._rank_docs_and_return_them_as_prompt(docs, 10_000) + + assert "/docs/second.md" not in ranked_prompt + assert ranked_prompt.index("/docs/third.md") < ranked_prompt.index("/docs/first.md") From c1a4398fc5bd58ad79c4471c81f919d416c5ccb5 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Mon, 14 Sep 2026 03:02:22 -0500 Subject: [PATCH 092/238] fix: correct /help project identity (#3367) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/settings/pr_help_prompts.toml | 2 +- tests/unittest/test_pr_help_message_rendering.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pr_agent/settings/pr_help_prompts.toml b/pr_agent/settings/pr_help_prompts.toml index 274940fd2a..ade4c5b667 100644 --- a/pr_agent/settings/pr_help_prompts.toml +++ b/pr_agent/settings/pr_help_prompts.toml @@ -1,5 +1,5 @@ [pr_help_prompts] -system="""You are Doc-helper, a language model designed to answer questions about a documentation website for an open-source project called "PR-Agent" (recently renamed to "Qodo Merge"). +system="""You are Doc-helper, a language model designed to answer questions about the open-source PR-Agent project. You will receive a question, and the full documentation website content. Your goal is to provide the best answer to the question using the documentation provided. diff --git a/tests/unittest/test_pr_help_message_rendering.py b/tests/unittest/test_pr_help_message_rendering.py index 1cf2ecc3cf..d00d1e2fd5 100644 --- a/tests/unittest/test_pr_help_message_rendering.py +++ b/tests/unittest/test_pr_help_message_rendering.py @@ -200,6 +200,13 @@ def test_question_source_urls_use_canonical_documentation_paths(file_name, heade assert tool.format_docs_url(file_name, header) == expected +def test_question_system_prompt_identifies_the_open_source_pr_agent_project(): + system_prompt, _ = PRHelpMessage._render_prompts({"question": "What is PR-Agent?", "snippets": ""}) + + assert "the open-source PR-Agent project" in system_prompt + assert "recently renamed" not in system_prompt.lower() + + @pytest.mark.parametrize( "provider_class, checkbox_commands, markdown_tables", [ From 729877a364a80656de34cbaf3c94ed17f4ee71da Mon Sep 17 00:00:00 2001 From: keii-2596 <106899633+keii-2596@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:26:14 +0800 Subject: [PATCH 093/238] docs: remove unsupported ignore_ticket_labels setting (#3369) --- docs/docs/usage-guide/additional_configurations.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/docs/docs/usage-guide/additional_configurations.md b/docs/docs/usage-guide/additional_configurations.md index f8eeaf1d2b..939910c5fe 100644 --- a/docs/docs/usage-guide/additional_configurations.md +++ b/docs/docs/usage-guide/additional_configurations.md @@ -541,19 +541,6 @@ ignore_language_framework = ['protobuf', ...] You can view the list of auto-generated file patterns in [`generated_code_ignore.toml`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/generated_code_ignore.toml). Files matching these glob patterns will be automatically excluded from PR Agent analysis. -### Ignoring Tickets with Specific Labels - -When PR-Agent analyzes tickets (JIRA, GitHub Issues, GitLab Issues, etc.) referenced in your PR, you may want to exclude tickets that have certain labels from the analysis. This is useful for filtering out tickets marked as "ignore-compliance", "skip-review", or other labels that indicate the ticket should not be considered during PR review. - -To ignore tickets with specific labels, add the following to your `configuration.toml` file: - -```toml -[config] -ignore_ticket_labels = ["ignore-compliance", "skip-review", "wont-fix"] -``` - -Where `ignore_ticket_labels` is a list of label names that should be ignored during ticket analysis. - ### Restricted Mode When running PR-Agent with limited GitHub/GitLab permissions, set `restricted_mode` to `true` to gracefully skip operations that require elevated access (e.g., pushing changelog changes): From 4fa842731b0816f8b49c5857e241a9d2410e9516 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:34:57 +0545 Subject: [PATCH 094/238] fix(improve): don't publish empty suggestions when anchors are unresolved (#3353) --- pr_agent/git_providers/plain_diff_provider.py | 2 +- pr_agent/tools/pr_code_suggestions.py | 152 +++++++++- .../unittest/test_pr_code_suggestions_core.py | 11 +- .../test_pr_code_suggestions_lifecycle.py | 236 ++++++++++++++- .../test_pr_code_suggestions_pr_chat.py | 13 +- .../test_pr_code_suggestions_rendering.py | 274 +++++++++++++++++- tests/unittest/test_run_details_wiring.py | 14 +- 7 files changed, 669 insertions(+), 33 deletions(-) diff --git a/pr_agent/git_providers/plain_diff_provider.py b/pr_agent/git_providers/plain_diff_provider.py index dcaa7f4f9a..2f55d5a57a 100644 --- a/pr_agent/git_providers/plain_diff_provider.py +++ b/pr_agent/git_providers/plain_diff_provider.py @@ -138,7 +138,7 @@ def publish_description(self, pr_title: str, pr_body: str): def is_supported(self, capability: str) -> bool: if capability in ["get_issue_comments", "create_inline_comment", "publish_inline_comments", - "get_labels"]: + "get_labels", "edit_comment", "remove_comment"]: return False return True diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 99667953f2..ed068f9101 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -1,6 +1,7 @@ import asyncio import copy import difflib +import math import re import textwrap import traceback @@ -109,6 +110,19 @@ def _supports_code_suggestion_state(git_provider) -> bool: return callable(supports) and bool(supports()) +def _supports_persistent_progress_comment(git_provider) -> bool: + """Whether a published progress comment can later be edited in place or removed. + + Hosted providers turn the progress note into the final suggestions comment (edit it) or + delete it on failure or cancellation, so publishing it up front is worthwhile. Output-only + providers (plain-diff) write every non-temporary comment straight through to their output + and can do neither; persisting the progress there would leak a stale "Preparing + suggestions..." document ahead of the final result. + """ + return (git_provider.is_supported("edit_comment") + and git_provider.is_supported("remove_comment")) + + def _edit_comment_safely(git_provider, comment, body: str) -> bool: try: result = git_provider.edit_comment(comment, body) @@ -282,9 +296,15 @@ async def run(self): not get_settings().config.get('is_auto_command', False)): if self.git_provider.is_supported("gfm_markdown"): # The progress comment later becomes the final suggestions comment (edited in place), - # so it must already be a thread when threaded output is requested. - self.progress_response = self.git_provider.publish_comment(self.progress, - **self._improve_thread_kwargs()) + # so it must already be a thread when threaded output is requested. Output-only + # providers (plain-diff) cannot edit or remove it afterwards, so for those the + # progress is a temporary placeholder that is never persisted. + if _supports_persistent_progress_comment(self.git_provider): + self.progress_response = self.git_provider.publish_comment(self.progress, + **self._improve_thread_kwargs()) + else: + self.progress_response = self.git_provider.publish_comment( + self.progress, is_temporary=True, **self._improve_thread_kwargs()) else: self.progress_response = self.git_provider.publish_comment( "Preparing suggestions...", is_temporary=True) @@ -316,6 +336,20 @@ async def run(self): if ((not get_settings().pr_code_suggestions.commitable_code_suggestions) and self.git_provider.is_supported("gfm_markdown")): + # Drop suggestions that can't be anchored in the diff (unresolved + # sentinels, zero/negative or reversed line ranges, or positive + # ranges that fall outside the changed lines of the relevant file) + # up front; when nothing survives, route the outcome through + # publish_no_suggestions() so it honors publish_output_no_suggestions + # and emits the accurate coverage footer instead of a header-only table. + data['code_suggestions'] = [ + suggestion for suggestion in data['code_suggestions'] + if self._is_suggestion_line_range_valid(suggestion) + ] + if not data['code_suggestions']: + await self.publish_no_suggestions() + return + # generate summarized suggestions pr_body = self.generate_summarized_suggestions(data) pr_body += self._get_suggestions_coverage_footer() @@ -829,7 +863,7 @@ async def _get_prediction(self, model: str, patches_diff: str, patches_diff_no_l if response_reflect: await self.analyze_self_reflection_response(data, response_reflect) else: - # get_logger().error(f"Could not self-reflect on suggestions. using default score 7") + get_logger().warning("Could not self-reflect on suggestions; using default score 7") for suggestion in data["code_suggestions"]: suggestion["score"] = 7 suggestion["score_why"] = "" @@ -873,7 +907,18 @@ async def _self_reflect_with_fallback(self, suggestion_list: List, patches_diff: async def analyze_self_reflection_response(self, data, response_reflect): response_reflect_yaml = load_yaml(response_reflect) + if not isinstance(response_reflect_yaml, dict): + get_logger().warning( + "Self-reflection feedback was not a mapping; line anchors will not be resolved" + ) + return code_suggestions_feedback = response_reflect_yaml.get("code_suggestions", []) + if not isinstance(code_suggestions_feedback, list): + get_logger().warning( + "Self-reflection feedback 'code_suggestions' was not a list; " + "line anchors will not be resolved" + ) + return if code_suggestions_feedback and len(code_suggestions_feedback) == len(data["code_suggestions"]): for i, suggestion in enumerate(data["code_suggestions"]): try: @@ -924,6 +969,11 @@ async def analyze_self_reflection_response(self, data, response_reflect): suggestion['existing_code'] = "" except Exception as e: get_logger().error(f"Error processing suggestion {i + 1}, error: {e}") + else: + get_logger().warning( + f"Self-reflection feedback covered {len(code_suggestions_feedback)} suggestion(s) instead of " + f"{len(data['code_suggestions'])}; line anchors will not be resolved" + ) @staticmethod def _truncate_if_needed(suggestion): @@ -939,6 +989,81 @@ def _truncate_if_needed(suggestion): suggestion['_is_truncated'] = True return suggestion + @staticmethod + def _parse_line_number(value) -> Optional[int]: + """Convert an anchor value to an int, or None when it cannot be a line number. + + Rejects booleans, fractional floats (int() truncates them), and non-finite + floats (int() raises OverflowError) instead of silently coercing them. + """ + if isinstance(value, bool): + return None + if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()): + return None + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return None + + def _is_suggestion_line_range_valid(self, suggestion: dict) -> bool: + relevant_lines_start = self._parse_line_number(suggestion.get('relevant_lines_start')) + relevant_lines_end = self._parse_line_number(suggestion.get('relevant_lines_end')) + relevant_file = suggestion.get('relevant_file') + if relevant_lines_start is None or relevant_lines_end is None: + get_logger().warning("Skipping a suggestion without a valid line range", + artifact={'relevant_file': relevant_file, + 'one_sentence_summary': suggestion.get('one_sentence_summary')}) + return False + if relevant_lines_start < 1 or relevant_lines_end < relevant_lines_start: + get_logger().warning("Skipping a suggestion with an invalid line range", + artifact={'relevant_file': relevant_file, + 'one_sentence_summary': suggestion.get('one_sentence_summary'), + 'relevant_lines_start': relevant_lines_start, + 'relevant_lines_end': relevant_lines_end}) + return False + suggestion['relevant_lines_start'] = relevant_lines_start + suggestion['relevant_lines_end'] = relevant_lines_end + if not self._is_suggestion_line_range_in_diff( + relevant_file, relevant_lines_start, relevant_lines_end): + return False + return True + + def _is_suggestion_line_range_in_diff( + self, + relevant_file, + relevant_lines_start: int, + relevant_lines_end: int) -> bool: + """Reject suggestions whose range cannot be resolved in the relevant file. + + The model reflects on the extended patch, so accept context outside the + raw hunk when the complete head file contains it, as in _validate_suggestion. + Without complete file content, use the raw hunk as the reference. + """ + if not isinstance(relevant_file, str) or not relevant_file.strip(): + get_logger().warning("Skipping a suggestion whose file is missing", + artifact={'relevant_file': relevant_file, + 'one_sentence_summary': None}) + return False + diff_file = self._get_diff_file(relevant_file.strip()) + if diff_file is None: + get_logger().warning("Skipping a suggestion whose file is not part of the PR diff", + artifact={'relevant_file': relevant_file.strip(), + 'relevant_lines_start': relevant_lines_start, + 'relevant_lines_end': relevant_lines_end}) + return False + if diff_file.head_file and getattr(diff_file, "head_file_is_complete", True): + range_resolvable = relevant_lines_end <= len(diff_file.head_file.splitlines()) + else: + range_resolvable = self._get_patch_range_lines( + diff_file.patch, relevant_lines_start, relevant_lines_end) is not None + if not range_resolvable: + get_logger().warning("Skipping a suggestion whose line range is not within the file", + artifact={'relevant_file': relevant_file.strip(), + 'relevant_lines_start': relevant_lines_start, + 'relevant_lines_end': relevant_lines_end}) + return False + return True + def _prepare_pr_code_suggestions(self, predictions: str) -> Dict: data = load_yaml(predictions.strip(), keys_fix_yaml=["relevant_file", "suggestion_content", "existing_code", "improved_code"], @@ -1229,11 +1354,10 @@ def _get_patch_range_lines(patch, relevant_lines_start, relevant_lines_end) -> O target_line += 1 target_remaining -= 1 - if all(line_number in target_lines - for line_number in range(relevant_lines_start, relevant_lines_end + 1)): - return [target_lines[line_number] - for line_number in range(relevant_lines_start, relevant_lines_end + 1)] - return None + if len(target_lines) != relevant_lines_end - relevant_lines_start + 1: + return None + return [target_lines[line_number] + for line_number in range(relevant_lines_start, relevant_lines_end + 1)] def _validate_suggestion(self, relevant_file, relevant_lines_start, relevant_lines_end, existing_code) -> tuple[bool, str, bool]: @@ -1776,11 +1900,21 @@ def generate_summarized_suggestions(self, data: Dict) -> str: suggestions_labels = dict() # add all suggestions related to each label for suggestion in data['code_suggestions']: + if not self._is_suggestion_line_range_valid(suggestion): + # suggestions without resolved line anchors (e.g. when self-reflection + # failed or returned a mismatched count) cannot be placed in the diff; + # skip them instead of failing the whole table + continue label = suggestion['label'].strip().strip("'").strip('"') if label not in suggestions_labels: suggestions_labels[label] = [] suggestions_labels[label].append(suggestion) + if not suggestions_labels: + pr_body = f"{format_pr_code_suggestions_header()}\n\n" + pr_body += "No suggestions found to improve this PR." + return pr_body + # sort suggestions_labels by the suggestion with the highest score suggestions_labels = dict( sorted(suggestions_labels.items(), key=lambda x: max([s['score'] for s in x[1]]), reverse=True)) diff --git a/tests/unittest/test_pr_code_suggestions_core.py b/tests/unittest/test_pr_code_suggestions_core.py index 6dffc7f9c3..093c846092 100644 --- a/tests/unittest/test_pr_code_suggestions_core.py +++ b/tests/unittest/test_pr_code_suggestions_core.py @@ -914,10 +914,10 @@ async def test_publish_no_suggestions_removes_the_progress_comment_when_quiet(pu git_provider.publish_comment.assert_not_called() -def _provider_with_file(head_file, filename="app.py"): +def _provider_with_file(head_file, filename="app.py", patch=""): git_provider = MagicMock() git_provider.diff_files = [ - FilePatchInfo(base_file="", head_file=head_file, patch="", filename=filename) + FilePatchInfo(base_file="", head_file=head_file, patch=patch, filename=filename) ] git_provider.publish_code_suggestions.return_value = True return git_provider @@ -930,7 +930,11 @@ def _published_suggestion(git_provider): def test_summarized_suggestions_use_the_target_file_indentation(): - git_provider = _provider_with_file("func f() {\n\told()\n}\n", filename="main.go") + git_provider = _provider_with_file( + "func f() {\n\told()\n}\n", + filename="main.go", + patch="@@ -1,3 +1,3 @@\n func f() {\n-\told()\n+\tnew()\n }\n", + ) git_provider.get_line_link.return_value = "https://example.com/main.go#L2" tool = _make_tool(git_provider) suggestion = _valid_suggestion( @@ -980,6 +984,7 @@ def test_summarized_suggestions_normalize_both_sides_of_the_diff(): git_provider = _provider_with_file( "func f() {\n\tif old() {\n\t\tkeep()\n\t}\n}\n", filename="main.go", + patch="@@ -1,5 +1,5 @@\n func f() {\n-\tif old() {\n+\tif new() {\n \t\tkeep()\n \t}\n }\n", ) git_provider.get_line_link.return_value = "https://example.com/main.go#L2-L4" tool = _make_tool(git_provider) diff --git a/tests/unittest/test_pr_code_suggestions_lifecycle.py b/tests/unittest/test_pr_code_suggestions_lifecycle.py index 973c19422d..d3a34646d0 100644 --- a/tests/unittest/test_pr_code_suggestions_lifecycle.py +++ b/tests/unittest/test_pr_code_suggestions_lifecycle.py @@ -4,7 +4,9 @@ import pytest +from pr_agent.algo.types import FilePatchInfo from pr_agent.config_loader import get_settings +from pr_agent.git_providers.plain_diff_provider import PlainDiffGitProvider from pr_agent.tools import pr_code_suggestions as pr_code_suggestions_module from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -20,6 +22,40 @@ "github.publish_as_check_run", ) +# A hunk whose new-file side covers lines 1-3, so a suggestion anchored at 1-1 +# passes the diff-hunk range validation in the summarized publish path. +_ANCHORED_HUNK_PATCH = ( + "@@ -1,3 +1,3 @@\n" + " line1\n" + "+line2-changed\n" + " line3\n" +) + + +def _anchored_suggestion(**overrides): + suggestion = { + "score": 1, + "relevant_file": "app.py", + "relevant_lines_start": 1, + "relevant_lines_end": 1, + "label": "maintainability", + "one_sentence_summary": "Anchored suggestion", + } + suggestion.update(overrides) + return suggestion + + +def _provider_with_anchored_diff(provider): + provider.diff_files = [ + FilePatchInfo( + base_file="line1\nline2\nline3\n", + head_file="line1\nline2-changed\nline3\n", + patch=_ANCHORED_HUNK_PATCH, + filename="app.py", + ) + ] + return provider + def _make_tool(provider): # A bare MagicMock returns a truthy mock here, which would thread every publish call. @@ -82,7 +118,7 @@ async def test_run_removes_progress_comment_when_cancelled( async def test_run_does_not_remove_final_summary_when_cancelled_during_dual_publishing(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -95,7 +131,7 @@ async def test_run_does_not_remove_final_summary_when_cancelled_during_dual_publ monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -122,7 +158,7 @@ async def test_run_does_not_remove_final_summary_when_cancelled_during_dual_publ async def test_run_does_not_publish_failure_after_successful_summary(monkeypatch, persistent_comment): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -135,7 +171,7 @@ async def test_run_does_not_publish_failure_after_successful_summary(monkeypatch monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -233,7 +269,9 @@ async def test_run_publishes_failure_when_inline_suggestions_never_publish(monke monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [ + {"score": 1, "relevant_lines_start": 1, "relevant_lines_end": 1} + ]}), ) _configure_published_run() settings = get_settings() @@ -252,7 +290,7 @@ async def test_run_publishes_failure_when_inline_suggestions_never_publish(monke async def test_run_does_not_remove_persistent_summary_when_cancelled_during_dual_publishing(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -267,7 +305,7 @@ async def test_run_does_not_remove_persistent_summary_when_cancelled_during_dual monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -322,7 +360,7 @@ async def test_run_preserves_cancellation_when_progress_cleanup_fails(monkeypatc async def test_run_cleans_up_progress_comment_on_check_run_publish(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -336,7 +374,7 @@ async def test_run_cleans_up_progress_comment_on_check_run_publish(monkeypatch): monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -360,7 +398,7 @@ async def test_run_cleans_up_progress_comment_on_check_run_publish(monkeypatch): async def test_run_retains_progress_handle_when_check_run_cleanup_fails(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -375,7 +413,7 @@ async def test_run_retains_progress_handle_when_check_run_cleanup_fails(monkeypa monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -515,3 +553,179 @@ async def test_failed_inline_retries_preserve_fallback_output( provider.remove_comment.assert_called_once_with(provider.publish_comment.return_value) finally: restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publish_output_no_suggestions", [True, False]) +async def test_run_routes_all_invalid_ranges_through_publish_no_suggestions( + monkeypatch, publish_output_no_suggestions +): + settings_snapshot = snapshot_settings( + _TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions",) + ) + try: + provider = MagicMock() + provider.get_files.return_value = [object()] + provider.is_supported.return_value = True + provider.supports_code_suggestions_artifact.return_value = False + tool = _make_tool(provider) + tool.publish_no_suggestions = AsyncMock() + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": -1, "relevant_lines_end": -1, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "sentinel"}, + {"relevant_lines_start": 5, "relevant_lines_end": 2, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "reversed"}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.publish_output_no_suggestions = publish_output_no_suggestions + + await tool.run() + + tool.publish_no_suggestions.assert_awaited_once() + provider.publish_comment.assert_not_called() + assert tool._output_published is False + finally: + restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publish_output_no_suggestions", [True, False]) +async def test_run_all_invalid_ranges_honors_quiet_gate_via_real_publish( + monkeypatch, publish_output_no_suggestions +): + settings_snapshot = snapshot_settings( + _TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions",) + ) + try: + provider = MagicMock() + provider.get_files.return_value = [object()] + provider.is_supported.return_value = True + provider.supports_code_suggestions_artifact.return_value = False + provider.publish_comment.return_value = MagicMock() + provider.should_publish_improve_as_thread.return_value = False + tool = _make_tool(provider) + tool.generate_summarized_suggestions = MagicMock(return_value="table") + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": -1, "relevant_lines_end": -1, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "sentinel"}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.publish_output_no_suggestions = publish_output_no_suggestions + + await tool.run() + + tool.generate_summarized_suggestions.assert_not_called() + if publish_output_no_suggestions: + comments = [call.args[0] for call in provider.publish_comment.call_args_list] + assert len(comments) == 1 + assert "No code suggestions found for the PR." in comments[0] + else: + provider.publish_comment.assert_not_called() + assert get_settings().data["artifact"] == "" + finally: + restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +async def test_run_plain_diff_leaks_no_progress_when_all_ranges_invalid(monkeypatch, capsys): + settings_snapshot = snapshot_settings( + _TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions", "plain_diff.content") + ) + try: + get_settings().set( + "plain_diff.content", + "diff --git a/foo.py b/foo.py\n" + "index 1111111..2222222 100644\n" + "--- a/foo.py\n" + "+++ b/foo.py\n" + "@@ -1,3 +1,3 @@\n" + " line1\n" + "-line2\n" + "+line2-changed\n" + " line3\n", + ) + provider = PlainDiffGitProvider(None) + tool = PRCodeSuggestions.__new__(PRCodeSuggestions) + tool.git_provider = provider + tool.pr_url = "https://example.invalid/pull/1" + tool.progress_response = None + tool.incremental = SimpleNamespace(is_incremental=False) + tool.progress = "## Generating PR code suggestions" + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": -1, "relevant_lines_end": -1, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "sentinel"}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = False + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + + await tool.run() + + out = capsys.readouterr().out + # The output-only provider cannot edit/remove a previously published comment, so + # the progress placeholder must never be persisted and only the final document remains. + assert "Generating PR code suggestions" not in out + assert out.count("## PR Code Suggestions") == 1 + assert out.count("No code suggestions found for the PR.") == 1 + finally: + restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publish_no_suggestions", [True, False]) +async def test_run_valid_ranges_skip_no_suggestions_comment(monkeypatch, publish_no_suggestions): + settings_snapshot = snapshot_settings(_TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions",)) + try: + provider = MagicMock() + provider.get_files.return_value = [object()] + provider.is_supported.return_value = True + provider.supports_code_suggestions_artifact.return_value = False + provider.publish_comment.return_value = MagicMock() + _provider_with_anchored_diff(provider) + tool = _make_tool(provider) + tool.publish_no_suggestions = AsyncMock() + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": "2", "relevant_lines_end": "2", "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "valid", + "suggestion_content": "Content", "existing_code": "old()", + "improved_code": "new()", "score": 7}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.publish_output_no_suggestions = publish_no_suggestions + + await tool.run() + + tool.publish_no_suggestions.assert_not_awaited() + assert tool._output_published is True + finally: + restore_settings(settings_snapshot) diff --git a/tests/unittest/test_pr_code_suggestions_pr_chat.py b/tests/unittest/test_pr_code_suggestions_pr_chat.py index a7966a4262..0c141f591c 100644 --- a/tests/unittest/test_pr_code_suggestions_pr_chat.py +++ b/tests/unittest/test_pr_code_suggestions_pr_chat.py @@ -3,6 +3,7 @@ import pytest +from pr_agent.algo.types import FilePatchInfo from pr_agent.config_loader import get_settings from pr_agent.git_providers.git_provider import GitProvider from pr_agent.git_providers.github_provider import GithubProvider @@ -106,6 +107,14 @@ async def test_pr_chat_link_depends_on_provider_capability(monkeypatch, supports provider.is_supported.side_effect = lambda capability: capability == "gfm_markdown" provider.supports_pr_chat.return_value = supports_pr_chat provider.should_publish_improve_as_thread.return_value = False + provider.diff_files = [ + FilePatchInfo( + base_file="old()\n", + head_file="new()\n", + patch="@@ -1,1 +1,1 @@\n-old()\n+new()\n", + filename="file.py", + ) + ] tool = PRCodeSuggestions.__new__(PRCodeSuggestions) tool.pr_url = "https://example.invalid/pr/1" @@ -116,7 +125,9 @@ async def test_pr_chat_link_depends_on_provider_capability(monkeypatch, supports tool.generate_summarized_suggestions = MagicMock(return_value="## Suggestions") async def _fake_retry(*_args, **_kwargs): - return {"code_suggestions": [{"label": "style", "suggestion_content": "clean up"}]} + return {"code_suggestions": [{"label": "style", "suggestion_content": "clean up", + "relevant_lines_start": 1, "relevant_lines_end": 1, + "relevant_file": "file.py"}]} monkeypatch.setattr(pr_code_suggestions_module, "retry_with_fallback_models", _fake_retry) diff --git a/tests/unittest/test_pr_code_suggestions_rendering.py b/tests/unittest/test_pr_code_suggestions_rendering.py index 9b0172831f..ac445f087d 100644 --- a/tests/unittest/test_pr_code_suggestions_rendering.py +++ b/tests/unittest/test_pr_code_suggestions_rendering.py @@ -36,6 +36,35 @@ def _suggestion(**overrides): return base +# A hunk whose new-file side covers lines 1-6 of the file. +DEFAULT_PATCH = ( + "@@ -1,5 +1,6 @@\n" + " def f():\n" + "- return old()\n" + "+ return new()\n" + " extra\n" + " more\n" + " lines\n" + "+ added line\n" +) + + +def _diff_file(filename="app.py", patch=DEFAULT_PATCH): + return FilePatchInfo( + base_file="def f():\n return old()\n extra\n more\n lines\n", + head_file="def f():\n return new()\n extra\n more\n lines\n added line\n", + patch=patch, + filename=filename, + ) + + +def _provider_with_diff_files(*filenames): + git_provider = MagicMock() + git_provider.diff_files = [_diff_file(filename) for filename in filenames] + git_provider.get_line_link.return_value = "" + return git_provider + + # --------------------------------------------------------------------------- # _truncate_if_needed # --------------------------------------------------------------------------- @@ -76,6 +105,110 @@ def test_truncate_if_needed_noop_when_under_limit_or_disabled(): restore_settings(snapshot) +@pytest.mark.parametrize( + "suggestion_kwargs", + [ + {"relevant_lines_start": None, "relevant_lines_end": 5}, + {"relevant_lines_start": -1, "relevant_lines_end": -1}, + {"relevant_lines_start": 0, "relevant_lines_end": 0}, + {"relevant_lines_start": -3, "relevant_lines_end": 1}, + {"relevant_lines_start": 5, "relevant_lines_end": 2}, + {"relevant_lines_start": "10", "relevant_lines_end": "bad"}, + {"relevant_lines_start": float("inf"), "relevant_lines_end": 5}, + {"relevant_lines_start": float("-inf"), "relevant_lines_end": 5}, + {"relevant_lines_start": 2, "relevant_lines_end": float("inf")}, + {"relevant_lines_start": float("nan"), "relevant_lines_end": 5}, + {"relevant_lines_start": 2, "relevant_lines_end": float("nan")}, + {"relevant_lines_start": 1.5, "relevant_lines_end": 5}, + {"relevant_lines_start": 2, "relevant_lines_end": 3.5}, + {"relevant_lines_start": True, "relevant_lines_end": True}, + ], +) +def test_is_suggestion_line_range_valid_rejects_unanchorable_ranges(suggestion_kwargs): + tool = _make_tool() + bad = _suggestion(**suggestion_kwargs) + + assert tool._is_suggestion_line_range_valid(bad) is False + + +def test_is_suggestion_line_range_valid_normalizes_valid_range(): + tool = _make_tool(_provider_with_diff_files("app.py")) + good = _suggestion(relevant_lines_start="2", relevant_lines_end="4") + + assert tool._is_suggestion_line_range_valid(good) is True + assert good["relevant_lines_start"] == 2 + assert good["relevant_lines_end"] == 4 + + +def test_is_suggestion_line_range_valid_rejects_missing_keys(): + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + +def test_is_suggestion_line_range_valid_rejects_file_not_in_diff(): + tool = _make_tool(_provider_with_diff_files("app.py")) + suggestion = _suggestion(relevant_file="other.py") + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + +def test_is_suggestion_line_range_valid_rejects_range_outside_diff(): + tool = _make_tool(_provider_with_diff_files("app.py")) + suggestion = _suggestion(relevant_lines_start=100, relevant_lines_end=100) + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + +def test_is_suggestion_line_range_valid_follows_the_head_file_when_available(): + # Extended context outside the raw hunk is anchorable within the head file. + diff_file = _diff_file() + git_provider = MagicMock() + git_provider.diff_files = [diff_file] + tool = _make_tool(git_provider) + suggestion = _suggestion(relevant_lines_start=5, relevant_lines_end=9) + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + diff_file.head_file += " trailing\n context\n lines\n" + + assert tool._is_suggestion_line_range_valid(suggestion) is True + + +@pytest.mark.parametrize("head_file", ["", "context\n" * 9]) +def test_is_suggestion_line_range_valid_uses_hunk_without_complete_head_file(head_file): + diff_file = _diff_file() + diff_file.head_file = head_file + diff_file.head_file_is_complete = False + git_provider = MagicMock() + git_provider.diff_files = [diff_file] + tool = _make_tool(git_provider) + + assert tool._is_suggestion_line_range_valid( + _suggestion(relevant_lines_start=2, relevant_lines_end=6)) is True + assert tool._is_suggestion_line_range_valid( + _suggestion(relevant_lines_start=5, relevant_lines_end=9)) is False + + +def test_get_patch_range_lines_rejects_oversized_span_without_enumerating_it(): + # A parseable but absurdly large span must be rejected by length comparison + # before any range(start, end) enumeration would iterate over it. + patch = DEFAULT_PATCH + + assert PRCodeSuggestions._get_patch_range_lines(patch, 1, 1_000_000_000) is None + assert PRCodeSuggestions._get_patch_range_lines(patch, 2, 6) == [ + " return new()", + " extra", + " more", + " lines", + " added line", + ] + assert PRCodeSuggestions._get_patch_range_lines(patch, 2, 2) == [" return new()"] + + def test_prepare_pr_code_suggestions_applies_truncation_inline(): settings = get_settings() snapshot = snapshot_settings(TRUNCATION_SETTINGS) @@ -307,7 +440,7 @@ def test_generate_summarized_suggestions_empty_returns_placeholder(): def test_generate_summarized_suggestions_renders_table_and_sorts_by_score(): - git_provider = MagicMock() + git_provider = _provider_with_diff_files("app.py", "auth.py") git_provider.get_line_link.return_value = "https://example.test/app.py#L2" tool = _make_tool(git_provider) settings = get_settings() @@ -343,8 +476,7 @@ def test_generate_summarized_suggestions_renders_table_and_sorts_by_score(): def test_generate_summarized_suggestions_uses_score_string_when_new_mechanism_enabled(): - git_provider = MagicMock() - git_provider.get_line_link.return_value = "" + git_provider = _provider_with_diff_files("app.py") tool = _make_tool(git_provider) settings = get_settings() snapshot = snapshot_settings(["pr_code_suggestions.new_score_mechanism"]) @@ -362,8 +494,7 @@ def test_generate_summarized_suggestions_uses_score_string_when_new_mechanism_en def test_generate_summarized_suggestions_escapes_angle_bracket_strings_in_summary(): - git_provider = MagicMock() - git_provider.get_line_link.return_value = "" + git_provider = _provider_with_diff_files("app.py") tool = _make_tool(git_provider) suggestion = _suggestion(one_sentence_summary="Replace '' with new_name") out = tool.generate_summarized_suggestions({"code_suggestions": [suggestion]}) @@ -375,8 +506,7 @@ def test_generate_summarized_suggestions_escapes_angle_bracket_strings_in_summar def test_generate_summarized_suggestions_includes_score_why_block_when_present(): - git_provider = MagicMock() - git_provider.get_line_link.return_value = "" + git_provider = _provider_with_diff_files("app.py") tool = _make_tool(git_provider) suggestion = _suggestion(score_why="Catches a real bug.") out = tool.generate_summarized_suggestions({"code_suggestions": [suggestion]}) @@ -384,6 +514,136 @@ def test_generate_summarized_suggestions_includes_score_why_block_when_present() assert "Why: Catches a real bug." in out +def test_generate_summarized_suggestions_skips_anchorless_but_keeps_rest(): + """A suggestion without resolved line anchors is skipped instead of failing the whole table.""" + git_provider = _provider_with_diff_files("app.py") + tool = _make_tool(git_provider) + anchored = _suggestion(one_sentence_summary="Keep me") + anchorless = _suggestion(one_sentence_summary="Drop me", relevant_file="other.py") + anchorless.pop("relevant_lines_start") + anchorless.pop("relevant_lines_end") + + out = tool.generate_summarized_suggestions({"code_suggestions": [anchorless, anchored]}) + + assert "" in out + assert "Keep me" in out + assert "Drop me" not in out + + +def test_generate_summarized_suggestions_skips_positive_range_outside_diff(): + """A positive, ordered range outside the file's diff hunks is dropped per-suggestion.""" + git_provider = _provider_with_diff_files("app.py") + tool = _make_tool(git_provider) + in_diff = _suggestion(one_sentence_summary="In diff") + out_diff = _suggestion( + one_sentence_summary="Hallucinated range", + relevant_lines_start=100, + relevant_lines_end=100, + ) + + out = tool.generate_summarized_suggestions({"code_suggestions": [in_diff, out_diff]}) + + assert "
    " in out + assert "In diff" in out + assert "Hallucinated range" not in out + + +def test_generate_summarized_suggestions_all_anchorless_returns_placeholder(): + """When no suggestion has resolvable anchors, publish a truthful message instead of an empty table.""" + git_provider = MagicMock() + git_provider.get_line_link.return_value = "" + tool = _make_tool(git_provider) + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + + out = tool.generate_summarized_suggestions({"code_suggestions": [suggestion]}) + + assert "No suggestions found to improve this PR." in out + assert "
    " not in out + + +@pytest.mark.parametrize( + "start,end", + [ + (-1, -1), # unresolved -1 sentinel written by self-reflection + (0, 0), + (-3, 1), + (5, 2), # reversed range + ], +) +def test_generate_summarized_suggestions_skips_invalid_line_ranges(start, end): + """Unresolved sentinels, non-positive lines and reversed ranges are omitted per-suggestion.""" + git_provider = _provider_with_diff_files("app.py") + tool = _make_tool(git_provider) + bad = _suggestion(one_sentence_summary="Bad range", relevant_lines_start=start, relevant_lines_end=end) + good = _suggestion(one_sentence_summary="Good range") + + out = tool.generate_summarized_suggestions({"code_suggestions": [bad, good]}) + + assert "
    " in out + assert "Good range" in out + assert "Bad range" not in out + + +@pytest.mark.parametrize("start,end", [(-1, -1), (0, 0), (5, 2)]) +def test_generate_summarized_suggestions_all_invalid_ranges_returns_placeholder(start, end): + git_provider = MagicMock() + git_provider.get_line_link.return_value = "" + tool = _make_tool(git_provider) + bad = _suggestion(relevant_lines_start=start, relevant_lines_end=end) + + out = tool.generate_summarized_suggestions({"code_suggestions": [bad]}) + + assert "No suggestions found to improve this PR." in out + assert "
    " not in out + + +@pytest.mark.asyncio +async def test_analyze_self_reflection_mismatched_count_does_not_crash(): + """Feedback covering a different suggestion count than generated leaves anchors unresolved.""" + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + data = {"code_suggestions": [suggestion, _suggestion(one_sentence_summary="Second")]} + + await tool.analyze_self_reflection_response( + data, "code_suggestions:\n- suggestion_score: 8\n why: fast\n" + ) + + assert "relevant_lines_start" not in data["code_suggestions"][0] + assert data["code_suggestions"][1]["relevant_lines_start"] == 2 + + +@pytest.mark.asyncio +async def test_analyze_self_reflection_null_feedback_does_not_crash(): + """A non-null response with `code_suggestions: null` must not abort suggestions.""" + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + data = {"code_suggestions": [suggestion]} + + await tool.analyze_self_reflection_response(data, "code_suggestions: null") + + assert "relevant_lines_start" not in data["code_suggestions"][0] + + +@pytest.mark.asyncio +async def test_analyze_self_reflection_non_mapping_feedback_does_not_crash(): + """A top-level list (not a mapping) from reflection must not abort suggestions.""" + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + data = {"code_suggestions": [suggestion]} + + await tool.analyze_self_reflection_response(data, "- one\n- two") + + assert "relevant_lines_start" not in data["code_suggestions"][0] + + # --------------------------------------------------------------------------- # Stale one-liner validation # --------------------------------------------------------------------------- diff --git a/tests/unittest/test_run_details_wiring.py b/tests/unittest/test_run_details_wiring.py index 5424034eab..5f47fc0c26 100644 --- a/tests/unittest/test_run_details_wiring.py +++ b/tests/unittest/test_run_details_wiring.py @@ -4,6 +4,7 @@ import pytest from pr_agent.algo.run_details import init_run_details, record_ai_call, record_model_used +from pr_agent.algo.types import FilePatchInfo from pr_agent.config_loader import get_settings from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions from pr_agent.tools.pr_description import PRDescription @@ -176,10 +177,21 @@ async def test_pr_code_suggestions_appends_run_details_only_when_enabled(monkeyp suggestions.git_provider = MagicMock() suggestions.git_provider.get_files.return_value = ["changed.py"] suggestions.git_provider.is_supported.side_effect = lambda cap: cap == "gfm_markdown" + suggestions.git_provider.diff_files = [ + FilePatchInfo( + base_file="old()\n", + head_file="new()\n", + patch="@@ -1,1 +1,1 @@\n-old()\n+new()\n", + filename="changed.py", + ) + ] suggestions.generate_summarized_suggestions = MagicMock(return_value="Base suggestions body") async def _fake_retry(*_args, **_kwargs): - return {"code_suggestions": [{"label": "style"}]} + return {"code_suggestions": [ + {"label": "style", "relevant_file": "changed.py", + "relevant_lines_start": 1, "relevant_lines_end": 1}, + ]} monkeypatch.setattr("pr_agent.tools.pr_code_suggestions.init_run_details", _seeded_init_run_details) monkeypatch.setattr("pr_agent.tools.pr_code_suggestions.retry_with_fallback_models", _fake_retry) From 8386ba5ebb5162c7471fa6e6f729f18bc4dabca9 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:35:08 +0545 Subject: [PATCH 095/238] ci: smoke-run /describe on a real GitHub PR (#3358) --- .github/workflows/provider-smoke.yaml | 32 +++++++ tests/health_test/test_github_smoke.py | 116 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 .github/workflows/provider-smoke.yaml create mode 100644 tests/health_test/test_github_smoke.py diff --git a/.github/workflows/provider-smoke.yaml b/.github/workflows/provider-smoke.yaml new file mode 100644 index 0000000000..a4a5546132 --- /dev/null +++ b/.github/workflows/provider-smoke.yaml @@ -0,0 +1,32 @@ +name: Provider smoke + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + github-provider-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Install dependencies + run: uv sync + + - name: Smoke /describe on a real GitHub PR + env: + GITHUB.USER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: PYTHONPATH=. uv run pytest tests/health_test/test_github_smoke.py -q diff --git a/tests/health_test/test_github_smoke.py b/tests/health_test/test_github_smoke.py new file mode 100644 index 0000000000..b0d3963320 --- /dev/null +++ b/tests/health_test/test_github_smoke.py @@ -0,0 +1,116 @@ +import asyncio +import copy +import os + +from starlette_context import context, request_cycle_context + +from pr_agent.agent.pr_agent import PRAgent +from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler +from pr_agent.config_loader import get_settings, global_settings + +# The default target is linked to #2934, so the smoke run +# exercises the ticket extraction path against live data. Override with +# TEST_PR_URL; assertions specific to the default target are then skipped. +DEFAULT_PR_URL = 'https://github.com/The-PR-Agent/pr-agent/pull/2940' + +DESCRIBE_STUB_YAML = """\ +title: | + fix: guard against empty commit list in Azure DevOps get_latest_commit_url +type: Enhancement +description: | + Synthetic PR description produced by the CI smoke stub so the run needs no model key. +pr_files: + - filename: | + pr_agent/git_providers/azuredevops_provider.py + changes_title: | + Guard the empty commit list + changes_summary: | + Return an empty string instead of raising IndexError when the commit list is empty + label: | + Enhancement + - filename: | + tests/unittest/test_azure_devops_comment.py + changes_title: | + Add empty-commit regression coverage + changes_summary: | + Cover get_latest_commit_url with an empty commit list + label: | + Tests +""" + + +class StubDescribeHandler(BaseAiHandler): + """Deterministic AI handler for the GitHub smoke test. + + Returns fixed YAML valid for the /describe pipeline, so the run exercises + provider auth, diff building, language sorting, ticket extraction and prompt + rendering end to end without any model key. + """ + + def __init__(self): + self.main_pr_language = "Python" + self.calls = [] + + @property + def deployment_id(self): + return "smoke-stub" + + async def chat_completion(self, model: str, system: str, user: str, + temperature: float = 0.2, img_path: str = None): + self.calls.append((system, user)) + return DESCRIBE_STUB_YAML, "stop" + + +def _extract_diff_section(user_prompt: str) -> str: + """Return the rendered diff body between the prompt's fixed markers.""" + start_marker = "The PR Git Diff:" + end_marker = "Note that lines in the diff body are prefixed" + start = user_prompt.find(start_marker) + end = user_prompt.find(end_marker) + assert start != -1 and end != -1 and end > start, "describe prompt is missing the diff section" + return user_prompt[start + len(start_marker):end].strip("=\n ").strip() + + +async def _run_smoke() -> None: + pr_url = os.getenv('TEST_PR_URL', DEFAULT_PR_URL) + + get_settings().set("config.git_provider", "github") + get_settings().set("config.publish_output", False) + get_settings().set("config.fallback_models", []) + get_settings().set("config.propagate_tool_errors", True) + + stub = StubDescribeHandler() + agent = PRAgent(ai_handler=lambda: stub) + ok = await agent.handle_request(pr_url, ['describe']) + + assert ok, "describe command failed against the target GitHub PR" + assert stub.calls, "the AI handler was never invoked - describe did not reach the model boundary" + + system_prompt, user_prompt = stub.calls[0] + assert isinstance(system_prompt, str) and system_prompt.strip(), "describe system prompt rendered empty" + diff_section = _extract_diff_section(user_prompt) + assert diff_section, "describe prompt diff section is empty" + + if pr_url == DEFAULT_PR_URL: + assert "azuredevops_provider.py" in diff_section, \ + "describe prompt is missing the default target PR diff content" + assert "Related Ticket Info:" in user_prompt, "describe prompt is missing the related-ticket section" + assert "Ticket Title: 'Azure DevOps get_latest_commit_url" in user_prompt, \ + "expanded ticket data did not reach the describe prompt" + + artifact = dict(get_settings().data).get('artifact', '') + assert isinstance(artifact, str) and artifact.startswith("###"), \ + f"Expected markdown artifact, got {type(artifact).__name__}" + assert "PR Type" in artifact, "PR description artifact missing the PR Type section" + assert "Description" in artifact, "PR description artifact missing the Description section" + + +def test_github_describe_smoke(): + global_settings.get("config") # force the lazy load before copying settings + with request_cycle_context({}): + context['settings'] = copy.deepcopy(global_settings) + asyncio.run(_run_smoke()) + + +if __name__ == '__main__': + test_github_describe_smoke() From 48e7c3689d59613bd54cbb1e89b38a5076062be0 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:39:34 +0545 Subject: [PATCH 096/238] feat: per-directory .pr_agent.toml for monorepo settings (#3282) Co-authored-by: Utsab --- .../usage-guide/configuration_reference.md | 3 + pr_agent/config_security.py | 101 ++ pr_agent/git_providers/git_provider.py | 26 + pr_agent/git_providers/github_provider.py | 110 ++ pr_agent/git_providers/gitlab_provider.py | 69 + pr_agent/git_providers/utils.py | 433 +++++- pr_agent/settings/configuration.toml | 3 + pr_agent/tools/pr_help_docs.py | 50 +- tests/unittest/test_config_reference.py | 2 +- tests/unittest/test_effort_bar_clamp.py | 12 +- tests/unittest/test_per_directory_settings.py | 1236 +++++++++++++++++ .../test_pr_help_docs_path_containment.py | 80 ++ 12 files changed, 2095 insertions(+), 30 deletions(-) create mode 100644 tests/unittest/test_per_directory_settings.py create mode 100644 tests/unittest/test_pr_help_docs_path_containment.py diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index ced2974895..904f347725 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -45,6 +45,9 @@ to-do list. | --- | --- | --- | | `use_repo_settings_file` | true | | | `use_global_settings_file` | true | | +| `enable_per_directory_settings` | false | when true, merge per-directory .pr_agent.toml files found by walking up from the PR's changed files (monorepo support). Adds bounded recursive tree discovery per MR; per-directory files may only override non-critical sections (see REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS). Nearest (deepest) directory wins on shared keys; equal-depth siblings resolve to the lexicographically-last path; when more files match than the per_directory_settings_max_files cap, shallower files are applied first and a partially capped depth keeps its later-path (winning) siblings; any overlap is logged as a warning. | +| `per_directory_settings_max_files` | 20 | hard ceiling on the number of per-directory .pr_agent.toml files applied per MR (deeper configs beyond the cap are skipped with a warning) | +| `per_directory_settings_max_tree_pages` | 10 | maximum GitLab recursive-tree pages (100 entries each); skip nested settings if discovery is incomplete. Root/host-controlled, independent of the settings-file cap. | | `extra_config_url` | "" | optional URL or path to an additional .pr_agent.toml merged before the repo-local config; also settable via --extra_config_url or PR_AGENT_EXTRA_CONFIG_URL. See docs/docs/usage-guide/configuration_options.md#external-configuration-url. | | `disable_auto_feedback` | false | | | `enable_auto_approval` | false | when true, /review may auto-approve a PR via auto_approve_logic(); that caller is currently commented out | diff --git a/pr_agent/config_security.py b/pr_agent/config_security.py index 226092cc58..b6e693be39 100644 --- a/pr_agent/config_security.py +++ b/pr_agent/config_security.py @@ -31,3 +31,104 @@ REPO_HOST_ONLY_KEYS_BY_SECTION = { "pr_reviewer": frozenset({"publish_error_details"}), } + +# Keys a per-directory `.pr_agent.toml` can never override, even when their section is +# otherwise open (None) in REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS. Nested files live in +# the working repository where any contributor can edit them, so keys that perform +# host-side writes (label mutation, resolving human review threads, publishing inline +# review findings, replacing pull-request labels with reviewer effort/security labels) or +# consume unbounded external resources (forcing a full issue-index refresh, scanning +# arbitrary issue counts, or repointing the vector backend) stay root-config- or host- +# controlled. Likewise, keys that rewrite pull-request metadata (AI title generation, +# comment-only description publication) stay root-controlled so a nested file cannot +# bypass the operator's choice of which PR fields the bot edits. Ticket extraction +# (require_ticket_analysis_review) stays root-controlled too: enabling it from a nested +# file would make the bot run authenticated Jira lookups the root config already disabled. +# Self-review workflow controls (demand_code_suggestions_self_review and +# approve_pr_on_self_review) stay root-controlled as well: a nested file must not be able +# to make /improve demand a checklist and then auto-approve the pull request when the +# author ticks it. Thread-history collection (pr_questions.use_conversation_history) is +# also root-controlled so a nested file cannot re-enable sending private review-thread +# discussion bodies to the model after the operator opted out. +# Similarly, budget/call-count controls (max_number_of_calls, max_ai_calls, parallel_calls, +# enable_large_pr_chunking, enable_large_pr_handling, async_ai_calls) are restricted so +# a nested file cannot multiply AI calls independently of the host-trusted defaults. +PER_DIRECTORY_HOST_ONLY_KEYS_BY_SECTION = { + "pr_reviewer": frozenset({ + "enable_large_pr_chunking", "max_number_of_calls", + "inline_key_issues", "enable_review_labels_security", + "enable_review_labels_effort", "require_estimate_effort_to_review", + "require_security_review", "require_ticket_analysis_review", + }), + "pr_description": frozenset({ + "publish_labels", "enable_large_pr_handling", "max_ai_calls", "async_ai_calls", + "generate_ai_title", "publish_description_as_comment", + "publish_description_as_comment_persistent", + }), + "pr_questions": frozenset({"resolve_threads", "use_conversation_history"}), + "pr_code_suggestions": frozenset({ + "commitable_code_suggestions", + "max_number_of_calls", "parallel_calls", + "approve_pr_on_self_review", "demand_code_suggestions_self_review", + }), + "pr_similar_issue": frozenset({"force_update_dataset", "max_issues_to_scan", "vectordb", "skip_comments"}), +} + +# Sections a *per-directory* `.pr_agent.toml` may override at all. Nested config +# files live in the working repository where any contributor can edit them, so this +# layer is deliberately narrower than the root/global repo settings: tool +# instructions, suggestion limits, ignore lists and model routing only. A frozenset +# value restricts the section to those keys; None allows every key in the section. +# Secrets, identity and deployment-critical settings (provider tokens, git_provider, +# push_outputs, skills, prompt_fragments, ...) can only be influenced from the root +# config or host environment, never from a nested file. +# +# Tool sections that can trigger bot-side writes (commits, changelog pushes) or +# read/connect from arbitrary URLs or paths are likewise restricted to drop-only keys: +# `pr_update_changelog` cannot be given `push_changelog_changes` (a nested config +# must not cause the bot to commit), and `pr_help_docs` cannot be given `repo_url`, +# `docs_path`, or `supported_doc_exts` (`repo_url` can be resolved into a token-embedded +# clone URL, while `docs_path` plus `supported_doc_exts` can point collection at any +# repository path or extension, so a nested file could read arbitrary source files into +# the model prompt). +# +# The `ignore` section is open to `glob` only: fnmatch translates glob patterns +# into bounded regexes, whereas `ignore.regex` accepts arbitrary expressions that +# filter_ignored() compiles and matches against every changed filename on every +# review. A catastrophic-backtracking pattern committed in a nested file could +# stall a worker, so nested files keep the bounded glob form only. +# +# The `config` section lists model-routing and output knobs but deliberately +# excludes the repo-context builders: `repo_context_files` fetches every listed +# file in full and `repo_context_max_lines` sizes the trimmed output, so without +# a host-trusted upper bound a nested file could balloon repository calls and +# token budget whenever a tool builds context for a directory it crosses. Those +# knobs stay root-/host-controlled. `fallback_models` is excluded too: the retry +# helper treats each entry as one routing attempt per failing model, so an +# arbitrarily long nested list could multiply AI calls; fallback routing stays +# root-/host-controlled. The token-budget keys (`max_model_tokens`, +# `custom_model_max_tokens`, `max_output_tokens`) are excluded as well because +# they directly size request context and completion limits, so without a trusted +# ceiling a nested file could inflate the size and cost of every model request. +REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS = { + "config": frozenset({ + "model", "model_weak", "model_reasoning", + "temperature", "response_language", + "repo_context_from_default_branch", + }), + "ignore": frozenset({"glob"}), + "pr_reviewer": None, + "pr_description": None, + "pr_questions": None, + "pr_code_suggestions": None, + "pr_custom_prompt": None, + "pr_add_docs": None, + "pr_update_changelog": frozenset({"extra_instructions", "add_pr_link"}), + "pr_analyze": None, + "pr_test": None, + "pr_improve_component": None, + "pr_help": None, + "pr_help_docs": frozenset({"exclude_root_readme", "enable_help_text"}), + "pr_similar_issue": None, + "pr_find_similar_component": None, +} diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 632565f531..69df2b0a6f 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -323,6 +323,17 @@ def clone(self, repo_url_to_clone: str, dest_folder: str, remove_dest_folder: bo def get_files(self) -> list: pass + def get_pr_file_paths(self) -> list: + """Return every repository-relative path the PR/MR touches, independent of + incremental review state, preserving rename metadata. + + The default delegates to get_files(). Providers whose get_files() shrinks + to the unreviewed subset while an incremental review is active must + override this with a complete file-set listing, so per-directory settings + discovery does not depend on how much of the PR the review has covered. + """ + return self.get_files() + @abstractmethod def get_diff_files(self) -> list[FilePatchInfo]: pass @@ -473,6 +484,21 @@ def _is_generated_by_pr_agent(self, description_lowercase: str) -> bool: def get_repo_settings(self): pass + def get_repo_settings_tree(self, ref: str = "") -> tuple[list[str], str]: + """Recursively list every `.pr_agent.toml` path at `ref` ("" = the repository + default branch) as `(paths, resolved_ref)`. Providers without per-directory + settings support return `([], "")` so the feature degrades to root-only + behavior. Implemented by GitHub and GitLab.""" + return [], "" + + def get_repo_settings_contents(self, paths: list[str], ref: str) -> dict[str, bytes]: + """Fetch the raw content of per-directory repo settings files at `ref`. + + Only the entries whose content was fetched successfully are returned; a + missing file is skipped with a warning rather than failing the request. + Defaults to no per-directory support.""" + return {} + def get_owning_namespace(self) -> Optional[str]: """Return the org/group/workspace that owns this repository, or None when the provider has no organisation-level home for global settings. diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 78b5322fb5..bc595a1f4b 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -78,6 +78,7 @@ def __init__(self, pr_url: Optional[str] = None): self.diff_files = None self.git_files = None self.incremental = IncrementalPR(False) + self._resolved_config_branch: str | None = None self._check_run_ids: dict = {} if pr_url and 'pull' in pr_url: self.set_pr(pr_url) @@ -264,6 +265,30 @@ def get_files(self): self.git_files = list(self.pr.get_files()) return self.git_files + def get_pr_file_paths(self): + """Return the complete PR file set regardless of incremental review state. + + get_files() returns only the unreviewed subset once an incremental review + is active, so per-directory settings would change between commands based on + which files the review already covered. Discovery instead walks the full PR + file set, preserving rename metadata (previous_filename so both sides of a + move apply). Reuses the same context["git_files"] cache as get_files() and + never falls back to the incremental-aware listing. + """ + try: + git_files = context.get("git_files", None) + if git_files: + return git_files + if getattr(self, "git_files", None): + return self.git_files + git_files = list(self.pr.get_files()) + context["git_files"] = git_files + return git_files + except Exception: + if getattr(self, "git_files", None): + return self.git_files + return list(self.pr.get_files()) + def get_num_of_files(self): if hasattr(self.git_files, "totalCount"): return self.git_files.totalCount @@ -1191,6 +1216,7 @@ def get_repo_settings(self): # left to propagate so they aren't masked by a silent fallback. try: contents = self.repo_obj.get_contents(".pr_agent.toml", ref=config_branch).decoded_content + self._resolved_config_branch = config_branch if settings_files: settings_files.append(("local", contents)) return settings_files @@ -1206,6 +1232,7 @@ def get_repo_settings(self): try: # more logical to take 'pr_agent.toml' from the default branch contents = self.repo_obj.get_contents(".pr_agent.toml").decoded_content + self._resolved_config_branch = getattr(self.repo_obj, "default_branch", "") or "" if config_branch and not settings_files: return contents settings_files.append(("local", contents)) @@ -1221,6 +1248,89 @@ def get_repo_settings(self): return settings_files if settings_files else "" + def get_repo_settings_tree(self, ref: str = "") -> tuple[list[str], str]: + """Recursively list every `.pr_agent.toml` at *ref* ("" = default branch). + + Follows the same branch resolution as get_repo_settings(): when the root + lookup resolved a config, the tree is read from that same branch + (``_resolved_config_branch``). When the root lookup resolved nothing (no + root ``.pr_agent.toml`` anywhere), the tree is read from *ref* -- the + CONFIG.CONFIG_BRANCH / PR_AGENT_CONFIG_BRANCH hint, or the repository + default branch when *ref* is empty -- falling back to the default branch + on a 404. Returns ``(paths, resolved_ref)`` where *resolved_ref* is the + branch the tree was actually read from; an empty *paths* list means the + recursive tree hit GitHub's truncation cap and per-directory settings had + to be skipped. + """ + repo = getattr(self, "repo_obj", None) + if repo is None: + return [], "" + resolved_ref = self._resolved_config_branch or ref or "" + try: + if not resolved_ref: + resolved_ref = repo.default_branch + return self._list_config_tree_paths(repo, resolved_ref), resolved_ref + except GithubException as e: + if e.status == 404 and resolved_ref: + # Branch or tree not found (possibly deleted between root and per-dir + # resolution). Fall back to the default branch; matches the root config + # fallback when CONFIG_BRANCH is stale. + get_logger().debug( + f"No git tree for branch '{resolved_ref}' while listing per-directory " + "settings; falling back to default branch" + ) + resolved_ref = repo.default_branch + return self._list_config_tree_paths(repo, resolved_ref), resolved_ref + # Unlike 404, a 403/5xx is not an expected fallback signal: propagate so it + # is not masked by a silent downgrade (matches get_repo_settings()). + raise + + def _list_config_tree_paths(self, repo, ref: str) -> list[str]: + """Fetch a recursive tree at *ref* and return its `.pr_agent.toml` blob paths. + + A truncated tree (GitHub caps recursive trees at 100k entries / 7 MB and sets + ``truncated``) cannot be trusted to name every config, so it degrades to no + per-directory settings with a warning rather than applying an incomplete, silent + subset. The root config is unaffected. + """ + tree = repo.get_git_tree(ref, recursive=True) + if getattr(tree, "truncated", False): + get_logger().warning( + f"Git tree for branch '{ref}' is truncated by GitHub's recursive-tree " + "limit; skipping per-directory settings for this repository" + ) + return [] + return self._extract_config_tree_paths(tree) + + @staticmethod + def _extract_config_tree_paths(tree) -> list[str]: + """Return repository-relative paths of every `.pr_agent.toml` blob in a + PyGithub GitTree object.""" + return [ + item.path + for item in getattr(tree, "tree", []) + if getattr(item, "type", None) == "blob" + and getattr(item, "path", "").endswith(".pr_agent.toml") + ] + + def get_repo_settings_contents(self, paths: list[str], ref: str) -> dict[str, bytes]: + """Fetch raw content of per-directory settings files at *ref*.""" + repo = getattr(self, "repo_obj", None) + if repo is None: + return {} + result: dict[str, bytes] = {} + for path in paths: + try: + result[path] = repo.get_contents(path, ref=ref).decoded_content + except GithubException as e: + if e.status == 404: + get_logger().warning( + f"Per-directory settings file '{path}' not found at ref '{ref}'; skipping" + ) + else: + raise + return result + def _get_global_settings_cache_key(self, repo_owner: str) -> str: # Cache per org AND host: the same org name on two different hosts (github.com vs a # self-hosted GitHub Enterprise instance) must not share a settings entry. diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 77299d5c21..8fed65583a 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -891,6 +891,18 @@ def get_files(self) -> list: self.git_files = [c.get('new_path') for c in raw_changes if c.get('new_path')] return self.git_files + def get_pr_file_paths(self) -> list: + """Return the complete MR file set regardless of incremental review state. + + get_files() returns only the unreviewed subset once an incremental review + is active, so per-directory settings would change between commands based on + which files the review already covered. Discovery instead walks the full MR + changes, keeping both old_path and new_path so both sides of a rename apply. + """ + raw_changes = self._get_merge_request_changes().get('changes', []) + raw_changes = self._expand_submodule_changes(raw_changes) + return [c for c in raw_changes if c.get('new_path') or c.get('old_path')] + def publish_description(self, pr_title: str, pr_body: str) -> None: try: if pr_title is not None: @@ -1430,6 +1442,63 @@ def get_repo_settings(self): get_logger().warning(f"Failed to load local .pr_agent.toml file, error: {e}") return settings_files if settings_files else "" + def get_repo_settings_tree(self, ref: str = "") -> tuple[list[str], str]: + """Recursively list every `.pr_agent.toml` at the repository default branch. + + GitLab root config is always read from the project default branch; the + per-directory layer follows the same branch so nested configs cannot read + a branch that the root does not use. ``ref`` is accepted for interface + compatibility but ignored — a future follow-up could add CONFIG_BRANCH + support here. + """ + if not getattr(self, "gl", None) or not getattr(self, "id_project", None): + return [], "" + try: + project = self.gl.projects.get(self.id_project) + resolved_ref = project.default_branch + max_pages = get_settings().config.per_directory_settings_max_tree_pages + if isinstance(max_pages, bool) or not isinstance(max_pages, int) or max_pages < 1: + get_logger().warning("Invalid per-directory tree page limit; skipping nested settings") + return [], resolved_ref + paths = [] + for page in range(1, max_pages + 1): + tree = project.repository_tree(ref=resolved_ref, recursive=True, page=page, per_page=100) + paths.extend( + item["path"] for item in tree + if item.get("type") == "blob" + and (item.get("path") or "").split("/")[-1] == ".pr_agent.toml" + ) + if len(tree) < 100: + return paths, resolved_ref + get_logger().warning("Per-directory tree page limit reached; skipping incomplete nested settings discovery") + return [], resolved_ref + except GitlabGetError as e: + if getattr(e, "response_code", None) == 404: + get_logger().debug("No repository tree found for per-directory settings; skipping") + return [], "" + raise + + def get_repo_settings_contents(self, paths: list[str], ref: str) -> dict[str, bytes]: + """Fetch raw content of per-directory settings files at *ref*.""" + if not getattr(self, "gl", None) or not getattr(self, "id_project", None): + return {} + project = self.gl.projects.get(self.id_project) + result: dict[str, bytes] = {} + for path in paths: + try: + content = project.files.get(file_path=path, ref=ref).decode() + if isinstance(content, str): + content = content.encode("utf-8") + result[path] = content + except GitlabGetError as e: + if getattr(e, "response_code", None) == 404: + get_logger().warning( + f"Per-directory settings file '{path}' not found at ref '{ref}'; skipping" + ) + else: + raise + return result + def _get_global_settings_cache_key(self, group: str) -> str: return f"gitlab:{getattr(self, 'gitlab_url', '')}:{group}" diff --git a/pr_agent/git_providers/utils.py b/pr_agent/git_providers/utils.py index 892ac58ed4..58d43ebaa2 100644 --- a/pr_agent/git_providers/utils.py +++ b/pr_agent/git_providers/utils.py @@ -1,5 +1,8 @@ import copy +import itertools +import math import os +import posixpath import re import tempfile import tomllib @@ -12,13 +15,25 @@ from starlette_context import context from pr_agent.config_loader import get_settings -from pr_agent.config_security import REPO_HOST_ONLY_KEYS_BY_SECTION, REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION +from pr_agent.config_security import ( + PER_DIRECTORY_HOST_ONLY_KEYS_BY_SECTION, + REPO_HOST_ONLY_KEYS_BY_SECTION, + REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION, + REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS, +) from pr_agent.custom_merge_loader import MAX_TOML_SIZE_IN_BYTES, validate_file_security from pr_agent.git_providers import get_git_provider_with_context from pr_agent.log import get_logger _MAX_EXTRA_CONFIG_BYTES = 1 * 1024 * 1024 # 1 MB cap for a remote .toml _FETCH_TIMEOUT_SECONDS = 10 +# Hard ceiling on the number of per-directory `.pr_agent.toml` files applied per MR, +# so a wide, multi-service diff cannot trigger a burst of config fetches. +_DEFAULT_MAX_PER_DIRECTORY_SETTINGS = 20 +# Aggregate byte budget for per-directory settings retained per MR. The per-file cap is +# MAX_TOML_SIZE_IN_BYTES (the same limit the loader applies); this trusted total keeps a +# batch of contributor-controlled files from being retained and parsed in bulk. +_PER_DIRECTORY_SETTINGS_AGGREGATE_BYTES = 5 * 1024 * 1024 # Bare Windows drive-letter paths (e.g. "C:\\shared.toml", "D:/cfg.toml"). # urlparse() would otherwise interpret the drive letter as a URL scheme. _WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]") @@ -225,6 +240,7 @@ def _apply_settings_from_file(path: str, label: str): def apply_repo_settings(pr_url): os.environ["AUTO_CAST_FOR_DYNACONF"] = "false" + _restore_per_directory_settings() # Apply external/shared config FIRST, before constructing the git provider: # provider initialisers (e.g. GitLabProvider reads GITLAB.PERSONAL_ACCESS_TOKEN @@ -278,22 +294,27 @@ def apply_repo_settings(pr_url): # misattribute the error to the last source; applying per-scope keeps error reporting # (and redaction) accurate and lets valid sources still take effect. for category, settings_content in _normalize_repo_settings(repo_settings): - fd, repo_settings_file = tempfile.mkstemp(suffix='.toml') - repo_settings_files.append(repo_settings_file) - if isinstance(settings_content, str): - settings_content = settings_content.encode("utf-8") - # os.fdopen takes ownership of fd (closes it) and write() writes all bytes, - # avoiding a silently-truncated file from a partial os.write. - with os.fdopen(fd, "wb") as settings_file_handle: - settings_file_handle.write(settings_content) + repo_settings_file = _write_settings_temp(settings_content, repo_settings_files) try: _apply_repo_settings_file(repo_settings_file) except Exception as e: get_logger().warning(f"Failed to apply repo {category} settings, error: {str(e)}") config_errors.append({'error': str(e), 'settings': settings_content, 'category': category}) - if config_errors: - handle_configurations_errors(config_errors, git_provider) + # Per-directory layer (monorepo support, opt-in): merge `.pr_agent.toml` files found in + # the directories the PR touches. Applied after the root config so a nearer file + # overrides a farther one; only non-critical sections are accepted (see + # REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS). + for category, settings_content in _get_per_directory_settings(git_provider): + repo_settings_file = _write_settings_temp(settings_content, repo_settings_files) + try: + _apply_repo_settings_file(repo_settings_file, repo_settings_scope="per_directory") + except Exception as e: + get_logger().warning(f"Failed to apply per-directory settings {category}, error: {str(e)}") + config_errors.append({'error': str(e), 'settings': settings_content, 'category': category}) + + if config_errors: + handle_configurations_errors(config_errors, git_provider) except Exception as e: get_logger().exception("Failed to apply repo settings", e) finally: @@ -308,12 +329,47 @@ def apply_repo_settings(pr_url): set_claude_model() -def _apply_repo_settings_file(repo_settings_file): +def _restore_per_directory_settings(): + """Remove the previous directory overlay before applying this command's trusted settings.""" + settings = get_settings() + previous = vars(settings).pop("_per_directory_original_values", {}) + for section, original_values in previous.items(): + contents = copy.deepcopy(settings.as_dict().get(section, {})) + for key, (present, value) in original_values.items(): + for current_key in list(contents): + if current_key.lower() == key: + del contents[current_key] + if present: + contents[key] = value + settings.unset(section) + if contents: + settings.set(section, contents, merge=False) + + +def _write_settings_temp(settings_content, repo_settings_files: list) -> str: + """Write a settings payload (str or bytes) to a temp .toml file registered for cleanup. + + os.fdopen takes ownership of the fd (closes it) and write() writes all bytes, + avoiding a silently-truncated file from a partial os.write. + """ + fd, repo_settings_file = tempfile.mkstemp(suffix='.toml') + repo_settings_files.append(repo_settings_file) + if isinstance(settings_content, str): + settings_content = settings_content.encode("utf-8") + with os.fdopen(fd, "wb") as settings_file_handle: + settings_file_handle.write(settings_content) + return repo_settings_file + + +def _apply_repo_settings_file(repo_settings_file, repo_settings_scope="repo"): """Load a single repo settings file and merge its allowed keys into the global settings. Enforces the per-repo host-key restrictions and logs only section names (values may contain - secrets). Raises on load/parse failure so the caller can attribute the error to the correct - settings scope (e.g. 'global' vs 'local'). + secrets). With ``repo_settings_scope="per_directory"`` the per-directory section allowlist + (REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS) is enforced on top, so a nested config committed by + any contributor cannot touch secrets/identity or deployment-critical settings that the root + config may set. Raises on load/parse failure so the caller can attribute the error to the + correct settings scope (e.g. 'global' vs 'local'). """ # Enforce the same size cap as the loader BEFORE parsing, so an oversized file can't be fully # read/parsed in-process (OOM/CPU) by the explicit validation below. @@ -338,6 +394,87 @@ def _apply_repo_settings_file(repo_settings_file): if not isinstance(contents, dict) or not contents: get_logger().debug(f"Skipping non-table or empty section: {section}") continue + if repo_settings_scope == "per_directory": + if section.lower() not in REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS: + get_logger().warning( + f"Ignoring section [{section}] from per-directory settings: only " + f"{sorted(REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS)} may be set per directory" + ) + continue + per_dir_allowed_keys = REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS[section.lower()] + if isinstance(per_dir_allowed_keys, frozenset): + rejected = [k for k in contents if k.lower() not in per_dir_allowed_keys] + if rejected: + get_logger().warning( + f"Ignoring non-overridable key(s) {rejected} in section [{section}] from " + f"per-directory settings; only {sorted(per_dir_allowed_keys)} may be set here" + ) + contents = {k: v for k, v in contents.items() if k.lower() in per_dir_allowed_keys} + if not contents: + continue + if section.lower() == "pr_description": + normalized_contents = {key.lower(): value for key, value in contents.items()} + threshold = normalized_contents.get("collapsible_file_list_threshold") + if ( + "collapsible_file_list_threshold" in normalized_contents + and ( + isinstance(threshold, bool) + or not isinstance(threshold, int) + or not 0 <= threshold <= 1000 + ) + ): + get_logger().warning( + "Ignoring invalid collapsible_file_list_threshold from per-directory settings; expected an " + "integer between 0 and 1000" + ) + contents = { + key: value for key, value in contents.items() + if key.lower() != "collapsible_file_list_threshold" + } + if not contents: + continue + per_dir_host_only_keys = PER_DIRECTORY_HOST_ONLY_KEYS_BY_SECTION.get(section.lower(), frozenset()) + rejected = [k for k in contents if k.lower() in per_dir_host_only_keys] + if rejected: + get_logger().warning( + f"Ignoring host-only key(s) {rejected} in section [{section}] from per-directory settings" + ) + contents = {k: v for k, v in contents.items() if k.lower() not in per_dir_host_only_keys} + if not contents: + continue + if section.lower() == "config": + normalized_contents = {key.lower(): value for key, value in contents.items()} + invalid_config_keys = [ + key for key in ("model", "model_weak", "model_reasoning", "response_language") + if key in normalized_contents + and (not isinstance(normalized_contents[key], str) or not normalized_contents[key].strip()) + ] + if invalid_config_keys: + get_logger().warning( + f"Ignoring non-string or empty setting(s) {invalid_config_keys} from per-directory settings" + ) + contents = { + key: value for key, value in contents.items() if key.lower() not in invalid_config_keys + } + if not contents: + continue + invalid_temperature = ( + "temperature" in normalized_contents + and ( + isinstance(normalized_contents["temperature"], bool) + or not isinstance(normalized_contents["temperature"], (int, float)) + or not math.isfinite(normalized_contents["temperature"]) + or not 0 <= normalized_contents["temperature"] <= 2 + ) + ) + if invalid_temperature: + get_logger().warning( + "Ignoring invalid temperature setting from per-directory settings; expected a finite number " + "between 0 and 2" + ) + contents = {key: value for key, value in contents.items() if key.lower() != "temperature"} + if not contents: + continue allowed_keys = REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION.get(section.lower()) if allowed_keys is not None: rejected = [k for k in contents if k.lower() not in allowed_keys] @@ -360,7 +497,20 @@ def _apply_repo_settings_file(repo_settings_file): if not contents: continue section_dict = copy.deepcopy(get_settings().as_dict().get(section.upper(), {})) + if repo_settings_scope == "per_directory": + previous = vars(get_settings()).setdefault("_per_directory_original_values", {}) + original_values = previous.setdefault(section.upper(), {}) + normalized = {key.lower(): value for key, value in section_dict.items()} + for key in contents: + original_values.setdefault( + key.lower(), (key.lower() in normalized, copy.deepcopy(normalized.get(key.lower())))) for key, value in contents.items(): + # Dynaconf looks up keys case-insensitively, so replacing the existing key + # (whatever its casing) keeps the newer value from a nearer/sibling config + # deterministic instead of leaving an "canonical-cased" duplicate beside it. + for existing_key in list(section_dict): + if existing_key.lower() == key.lower(): + del section_dict[existing_key] section_dict[key] = value get_settings().unset(section) get_settings().set(section, section_dict, merge=False) @@ -381,6 +531,261 @@ def _normalize_repo_settings(repo_settings): return repo_settings +def _get_config_branch() -> str: + """Resolve the branch repo configuration is read from. + + Mirrors the per-provider resolution (CONFIG.CONFIG_BRANCH / PR_AGENT_CONFIG_BRANCH); + returns "" when unset, meaning the provider reads from its own default branch. + """ + settings_branch = get_settings().get("CONFIG.CONFIG_BRANCH", None) + settings_branch = settings_branch.strip() if isinstance(settings_branch, str) else "" + env_branch = (os.environ.get("PR_AGENT_CONFIG_BRANCH") or "").strip() + return settings_branch or env_branch + + +def _get_changed_file_paths(git_provider) -> list[str]: + """Return the repository-relative paths the PR/MR touches. + + Includes rename metadata (old_path / previous_filename) when the provider + surfaces it, so ancestor configs for both sides of a move apply. Prefers the + complete PR file set (get_pr_file_paths) over get_files(), which many + providers narrow to the unreviewed subset while an incremental review is + active; discovery must always see the whole PR so applied settings do not + change between commands. Tolerates each provider's entry shape (str, dict + keyed by new_path/filename/path, or an object with .filename/.new_path). A + failure to list files degrades to no per-directory configs rather than + failing the request. + """ + try: + full_paths = getattr(git_provider, "get_pr_file_paths", None) + files = full_paths() if full_paths is not None else git_provider.get_files() + except Exception as e: + get_logger().warning(f"Failed to list changed files for per-directory settings: {e}") + return [] + paths = [] + for entry in files or []: + for name in _entry_path_names(entry): + if name not in paths: + paths.append(name) + return paths + + +def _entry_path_names(entry) -> list[str]: + """Extract any repository-relative path names an entry carries (new and old).""" + if isinstance(entry, str): + return [entry.strip()] if entry.strip() else [] + if isinstance(entry, dict): + keys = ("new_path", "filename", "path", "old_path", "previous_filename") + else: + keys = ("filename", "new_path", "old_path", "previous_filename") + names = [] + for key in keys: + value = entry.get(key) if isinstance(entry, dict) else getattr(entry, key, None) + if isinstance(value, str) and value.strip() and value != "/dev/null": + names.append(value.strip()) + return names + + +def _get_per_directory_settings(git_provider) -> list: + """Resolve the `.pr_agent.toml` files for the directories an MR touches. + + Walks up from each changed file toward the repository root and keeps every + ancestor config whose directory has one (the root `.pr_agent.toml` is already + applied through get_repo_settings()). Results are returned ordered + shallowest-directory-first so a nearer (more specific) file overrides a farther + one on shared keys; list/dict values replace rather than concatenate, matching + the loader. + + With a diff spanning several sibling directories (e.g. ``services/auth`` and + ``services/billing``), all of their configs apply to the whole MR: an explicit + deterministic tie-break orders equal-depth directories by path, so the + lexicographically-last sibling wins on a shared key. That is only meaningful as + a fallback, so overlapping keys between same-depth configs applied to one MR are + reported with a warning naming the conflict and the winner. + + Returns [] when the feature is disabled, the provider lacks per-directory support, + the tree has no nested configs, or nothing is crossed. + """ + settings = get_settings() + if not settings.config.get("enable_per_directory_settings", False): + return [] + config_branch = _get_config_branch() + tree_method = getattr(git_provider, "get_repo_settings_tree", None) + if tree_method is None: + return [] + try: + tree_paths, resolved_ref = tree_method(config_branch) + except Exception as e: + get_logger().warning(f"Failed to list per-directory settings candidates: {e}") + return [] + # The root `.pr_agent.toml` (directory '') is already applied through + # get_repo_settings(); only nested directories add per-directory behavior. + config_dirs = { + posixpath.dirname(p) + for p in tree_paths + if posixpath.basename(p) == ".pr_agent.toml" and posixpath.dirname(p) + } + if not config_dirs: + return [] + changed_paths = _get_changed_file_paths(git_provider) + if not changed_paths: + return [] + + try: + max_configs = int(settings.config.get( + "per_directory_settings_max_files", _DEFAULT_MAX_PER_DIRECTORY_SETTINGS)) + except (TypeError, ValueError): + max_configs = _DEFAULT_MAX_PER_DIRECTORY_SETTINGS + max_configs = max(1, max_configs) + crossed = set() + for changed_path in changed_paths: + directory = posixpath.dirname(changed_path) + while directory: + if directory in config_dirs: + crossed.add(directory) + parent = posixpath.dirname(directory) + if parent == directory: + break + directory = parent + + if len(crossed) > max_configs: + get_logger().warning( + f"{len(crossed)} per-directory .pr_agent.toml files apply to this PR; " + f"applying at most {max_configs}, preferring shallower files and keeping " + f"later-path winners within a partially included depth" + ) + # Shallowest (closest to root) first: later files override earlier ones, so the + # nearest directory wins on scalar keys. Equal-depth siblings are ordered by + # path, so the lexicographically-last directory wins deterministically (any + # overlap is surfaced by _warn_on_sibling_key_conflicts). When the cap cuts + # through an equal-depth group, the lexicographically-later (winning) entries + # are retained and the earlier ones dropped, so the documented winner still + # participates in the merge. + ordered = sorted(crossed, key=lambda directory: (directory.count("/"), directory)) + if len(ordered) > max_configs: + kept = [] + budget = max_configs + for _, siblings in itertools.groupby(ordered, key=lambda directory: directory.count("/")): + siblings = list(siblings) + if budget >= len(siblings): + kept.extend(siblings) + budget -= len(siblings) + continue + kept.extend(siblings[len(siblings) - budget:]) + break + ordered = kept + if not ordered: + return [] + + contents_method = getattr(git_provider, "get_repo_settings_contents", None) + if contents_method is None: + return [] + config_paths = [f"{directory}/.pr_agent.toml" for directory in ordered] + contents = contents_method(config_paths, resolved_ref) or {} + # Reject oversized or over-budget payloads before any conflict inspection or + # retention: files are contributor-controlled and unvalidated at fetch time, so a + # batch of them must not be held and parsed wholesale in one worker. + contents = _size_bounded_per_directory_contents(config_paths, contents) + _warn_on_sibling_key_conflicts(ordered, contents) + resolved = [] + for path in config_paths: + content = contents.get(path) + if content is None: + continue + resolved.append((path, content)) + if resolved: + get_logger().info( + f"Applying {len(resolved)} per-directory settings file(s): " + f"{sorted(entry[0] for entry in resolved)}" + ) + return resolved + + +def _size_bounded_per_directory_contents(config_paths: list[str], contents: dict[str, bytes]) -> dict[str, bytes]: + """Drop per-directory payloads that exceed the size caps before any parsing or retention. + + Per-directory files are contributor-controlled and unvalidated at fetch time. Payloads + larger than ``MAX_TOML_SIZE_IN_BYTES`` are skipped, and retention stops once the + aggregate byte budget is exhausted, so a batch of oversized nested files cannot be kept + and parsed wholesale. Files already downloaded by the provider are unavoidable, but they + are not retained, parsed, or logged beyond a bounded warning. + """ + bounded: dict[str, bytes] = {} + total_bytes = 0 + for path in config_paths: + content = contents.get(path) + if content is None: + continue + size = len(content) + if size > MAX_TOML_SIZE_IN_BYTES: + get_logger().warning( + f"Per-directory settings file '{path}' is {size} bytes " + f"(> {MAX_TOML_SIZE_IN_BYTES}); skipping it" + ) + continue + if total_bytes + size > _PER_DIRECTORY_SETTINGS_AGGREGATE_BYTES: + get_logger().warning( + f"Per-directory settings aggregate exceeds " + f"{_PER_DIRECTORY_SETTINGS_AGGREGATE_BYTES} bytes; skipping further files" + ) + break + bounded[path] = content + total_bytes += size + return bounded + + +def _warn_on_sibling_key_conflicts(ordered: list[str], contents: dict[str, bytes]) -> list[tuple[str, str, str]]: + """Log (section, key) collisions between equal-depth sibling configs. + + Equal-depth directories are applied in path order (see _get_per_directory_settings), + so the lexicographically-last sibling wins on a shared key. Surface any such overlap + instead of letting it stay an arbitrary-looking silent choice. Returns the detected + conflicts as ``(section, key, winning_directory)`` for tests; parsing failures are + ignored here, since the real file is validated when applied and then reported + through handle_configurations_errors(). + """ + conflicts: list[tuple[str, str, str]] = [] + grouped = itertools.groupby(sorted(ordered, key=lambda d: d.count("/")), + key=lambda d: d.count("/")) + for _, group in grouped: + group = list(group) + if len(group) < 2: + continue + parsed = {} + for directory in group: + content = contents.get(f"{directory}/.pr_agent.toml") + if not content: + continue + if len(content) > MAX_TOML_SIZE_IN_BYTES: + get_logger().warning( + f"Per-directory settings file '{directory}/.pr_agent.toml' exceeds " + f"{MAX_TOML_SIZE_IN_BYTES} bytes; skipping sibling-conflict inspection" + ) + continue + try: + parsed[directory] = tomllib.loads(content.decode("utf-8")) + except Exception: + continue + key_owners: dict[tuple[str, str], list[str]] = {} + for directory, data in parsed.items(): + for section, table in data.items(): + if not isinstance(table, dict): + continue + for key in table: + key_owners.setdefault((section.lower(), key.lower()), []).append(directory) + for (section, key), owners in sorted(key_owners.items()): + if len(owners) < 2: + continue + winner_directory = owners[-1] + get_logger().warning( + f"Per-directory settings at the same depth set the same key " + f"'{section}.{key}': sibling configs {sorted(owners)} all set it " + f"and '{winner_directory}/.pr_agent.toml' wins (later path overrides)" + ) + conflicts.append((section, key, winner_directory)) + return conflicts + + def handle_configurations_errors(config_errors, git_provider): try: if not any(config_errors): diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index fc7eb969dd..a372954e9f 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -21,6 +21,9 @@ log_level="DEBUG" # Configurations use_repo_settings_file=true use_global_settings_file=true +enable_per_directory_settings=false # when true, merge per-directory .pr_agent.toml files found by walking up from the PR's changed files (monorepo support). Adds bounded recursive tree discovery per MR; per-directory files may only override non-critical sections (see REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS). Nearest (deepest) directory wins on shared keys; equal-depth siblings resolve to the lexicographically-last path; when more files match than the per_directory_settings_max_files cap, shallower files are applied first and a partially capped depth keeps its later-path (winning) siblings; any overlap is logged as a warning. +per_directory_settings_max_files=20 # hard ceiling on the number of per-directory .pr_agent.toml files applied per MR (deeper configs beyond the cap are skipped with a warning) +per_directory_settings_max_tree_pages=10 # maximum GitLab recursive-tree pages (100 entries each); skip nested settings if discovery is incomplete. Root/host-controlled, independent of the settings-file cap. extra_config_url="" # optional URL or path to an additional .pr_agent.toml merged before the repo-local config; also settable via --extra_config_url or PR_AGENT_EXTRA_CONFIG_URL. See docs/docs/usage-guide/configuration_options.md#external-configuration-url. disable_auto_feedback = false enable_auto_approval = false # when true, /review may auto-approve a PR via auto_approve_logic(); that caller is currently commented out diff --git a/pr_agent/tools/pr_help_docs.py b/pr_agent/tools/pr_help_docs.py index 0d30a748da..5f7afbba14 100644 --- a/pr_agent/tools/pr_help_docs.py +++ b/pr_agent/tools/pr_help_docs.py @@ -461,6 +461,24 @@ def _find_all_document_files_matching_exts(self, abs_docs_path: str, ignore_read get_logger().exception("Unexpected exception thrown. Returning empty list.") return [] + def _filter_doc_files_under_clone(self, doc_files: list[str], clone_root: str) -> list[str]: + """Drop candidates whose resolved path leaves the cloned repository root. + + Symlinked documentation files can point outside the clone at host files; resolving + each candidate keeps anything that escapes from ever being read into the model prompt. + """ + contained = [] + for file_path in doc_files: + resolved = os.path.realpath(file_path) + if resolved != clone_root and not resolved.startswith(clone_root + os.sep): + get_logger().warning( + f"Documentation file '{file_path}' resolves outside the cloned repository " + "root; skipping it" + ) + continue + contained.append(file_path) + return contained + def _gen_filenames_to_contents_map_from_repo(self) -> dict[str, str]: try: with TemporaryDirectory() as tmp_dir: @@ -478,15 +496,29 @@ def _gen_filenames_to_contents_map_from_repo(self) -> dict[str, str]: for file in files: if file.lower().startswith("readme."): doc_files.append(os.path.join(root, file)) - abs_docs_path = os.path.join(returned_cloned_repo_root.path, self.docs_path) - if os.path.exists(abs_docs_path): - doc_files.extend(self._find_all_document_files_matching_exts(abs_docs_path, - ignore_readme=(self.docs_path=='.'))) - if not doc_files: - get_logger().warning(f"No documentation files found matching file extensions: " - f"{self.supported_doc_exts} under repo: {self.repo_url} " - f"path: {self.docs_path}. Returning empty dict.") - return {} + # Resolve docs_path against the clone root and refuse paths that escape it: + # an absolute or traversing docs_path (e.g. an attacker-controlled per-directory + # setting) must never read host files into the prompt. + clone_root = os.path.realpath(returned_cloned_repo_root.path) + resolved_docs_path = os.path.realpath(os.path.join(clone_root, self.docs_path)) + if resolved_docs_path == clone_root or resolved_docs_path.startswith(clone_root + os.sep): + if os.path.exists(resolved_docs_path): + doc_files.extend(self._find_all_document_files_matching_exts(resolved_docs_path, + ignore_readme=(self.docs_path=='.'))) + else: + get_logger().warning( + f"docs_path '{self.docs_path}' escapes the cloned repository root; " + "skipping docs-path file gathering" + ) + # Individual files can still escape the clone through symlinks (a doc or root + # README that points at a host file). Resolve every candidate and refuse + # anything that leaves the canonical clone root so host files never reach the prompt. + doc_files = self._filter_doc_files_under_clone(doc_files, clone_root) + if not doc_files: + get_logger().warning(f"No documentation files found matching file extensions: " + f"{self.supported_doc_exts} under repo: {self.repo_url} " + f"path: {self.docs_path}. Returning empty dict.") + return {} get_logger().info(f'For context {self.ctx_url} and repo: {self.repo_url}' f' will be using the following documentation files: ', diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index e0adc5f584..7b9ac8f335 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 256 + assert sum(keys.values()) == 259 assert { "model", diff --git a/tests/unittest/test_effort_bar_clamp.py b/tests/unittest/test_effort_bar_clamp.py index b11484b3c4..a411e66ac3 100644 --- a/tests/unittest/test_effort_bar_clamp.py +++ b/tests/unittest/test_effort_bar_clamp.py @@ -40,7 +40,7 @@ def test_the_bar_is_always_five_segments(): @pytest.mark.parametrize("score, expected", [("8", 5), ("0", 1), ("3", 3)]) -def test_the_review_label_matches_the_rendered_bar(score, expected): +def test_the_review_label_matches_the_rendered_bar(score, expected, monkeypatch): """Clamp the label the same way as the bar, so the two never disagree.""" from pr_agent.config_loader import get_settings from pr_agent.tools.pr_reviewer import PRReviewer @@ -54,11 +54,11 @@ def test_the_review_label_matches_the_rendered_bar(score, expected): })() settings = get_settings(use_context=False) - settings.set("pr_reviewer.enable_review_labels_effort", True) - settings.set("pr_reviewer.enable_review_labels_security", False) - settings.set("pr_reviewer.require_estimate_effort_to_review", True) - settings.set("pr_reviewer.require_security_review", False) - settings.set("config.publish_output", True) + monkeypatch.setattr(settings.pr_reviewer, "enable_review_labels_effort", True) + monkeypatch.setattr(settings.pr_reviewer, "enable_review_labels_security", False) + monkeypatch.setattr(settings.pr_reviewer, "require_estimate_effort_to_review", True) + monkeypatch.setattr(settings.pr_reviewer, "require_security_review", False) + monkeypatch.setattr(settings.config, "publish_output", True) reviewer.set_review_labels({"review": {"estimated_effort_to_review_[1-5]": score}}) assert published and published[0] == [f"Review effort {expected}/5"] diff --git a/tests/unittest/test_per_directory_settings.py b/tests/unittest/test_per_directory_settings.py new file mode 100644 index 0000000000..5db49efdd0 --- /dev/null +++ b/tests/unittest/test_per_directory_settings.py @@ -0,0 +1,1236 @@ +import copy +from types import SimpleNamespace +from unittest.mock import MagicMock, call + +import pytest +from github import GithubException +from starlette_context import context, request_cycle_context + +from pr_agent.config_loader import get_settings, global_settings +from pr_agent.git_providers import utils as git_utils +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.git_providers.gitlab_provider import GitLabProvider + +ROOT_TOML = b""" +[pr_reviewer] +num_max_findings = 10 + +[config] +model = "root-model" +temperature = 0.1 +""" + +SERVICES_TOML = b""" +[pr_reviewer] +num_max_findings = 5 + +[ignore] +glob = ["*.gen.js"] +""" + +SERVICES_AUTH_TOML = b""" +[pr_reviewer] +num_max_findings = 3 + +[config] +temperature = 0.5 +model = "auth-model" +""" + +SERVICES_BILLING_TOML = b""" +[pr_reviewer] +num_max_findings = 7 +""" + + +class FakePerDirProvider: + def __init__(self, root_settings=None, tree_paths=(), contents=None, files=None, + resolved_ref="main", full_files=None): + self.root_settings = root_settings + self.tree_paths = tuple(tree_paths) + self.contents = dict(contents) if contents else {} + self.files = list(files) if files else [] + self.full_files = list(full_files) if full_files is not None else None + self.resolved_ref = resolved_ref + self.tree_calls = 0 + self.tree_refs = [] + self.contents_calls = [] + self.get_files_calls = 0 + self.pr_file_paths_calls = 0 + + def get_repo_settings(self): + return self.root_settings + + def get_files(self): + self.get_files_calls += 1 + return self.files + + def get_pr_file_paths(self): + self.pr_file_paths_calls += 1 + return self.full_files if self.full_files is not None else self.files + + def get_repo_settings_tree(self, ref): + self.tree_calls += 1 + self.tree_refs.append(ref) + return list(self.tree_paths), self.resolved_ref + + def get_repo_settings_contents(self, paths, ref): + self.contents_calls.append((list(paths), ref)) + return {path: self.contents[path] for path in paths if path in self.contents} + + def is_supported(self, capability): + return False + + def publish_comment(self, body): + pass + + def publish_persistent_comment(self, *args, **kwargs): + pass + + +@pytest.fixture +def fresh_global_settings(): + """Restore module-level global_settings after each test in case anything mutated it.""" + snapshot = copy.deepcopy(global_settings.as_dict()) + yield + for section in set(global_settings.as_dict().keys()) - set(snapshot.keys()): + global_settings.unset(section) + for section, contents in snapshot.items(): + global_settings.unset(section) + global_settings.set(section, copy.deepcopy(contents), merge=False) + + +@pytest.fixture +def per_dir_settings(fresh_global_settings): + """Request-scoped settings clone with the per-directory feature enabled.""" + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + settings = get_settings() + settings.config.enable_per_directory_settings = True + yield + + +def _provider(tree_paths, contents, files, root_settings=b"", full_files=None): + return FakePerDirProvider( + root_settings=root_settings, + tree_paths=tree_paths, + contents=contents, + files=files, + full_files=full_files, + ) + + +class TestResolvePerDirectorySettings: + def test_literal_backslashes_do_not_activate_nested_settings(self, per_dir_settings): + provider = _provider( + tree_paths=["services/auth/.pr_agent.toml"], + contents={"services/auth/.pr_agent.toml": SERVICES_AUTH_TOML}, + files=[r"services\auth\file.py"], + ) + + assert git_utils._get_per_directory_settings(provider) == [] + assert provider.contents_calls == [] + + def test_walks_up_from_changed_file_to_root(self, per_dir_settings): + provider = _provider( + tree_paths=[".pr_agent.toml", "services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["services/auth/api.py", "other/README.md"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # The root .pr_agent.toml is already applied through get_repo_settings() and + # must not be re-applied here; ancestor configs are returned shallowest-first + # so a nearer file overrides a farther one. + assert [path for path, _ in resolved] == [ + "services/.pr_agent.toml", + "services/auth/.pr_agent.toml", + ] + + def test_sibling_configs_both_applied(self, per_dir_settings): + provider = _provider( + tree_paths=["services/auth/.pr_agent.toml", "services/billing/.pr_agent.toml"], + contents={ + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + }, + files=["services/auth/api.py", "services/billing/x.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + assert sorted(path for path, _ in resolved) == [ + "services/auth/.pr_agent.toml", + "services/billing/.pr_agent.toml", + ] + + def test_sibling_overlap_is_detected_and_warned(self): + from loguru import logger as loguru_logger + + ordered = ["services/auth", "services/billing"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + } + + captured_lines = [] + sink_id = loguru_logger.add( + lambda msg: captured_lines.append(str(msg)), + level="WARNING", + ) + try: + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + finally: + loguru_logger.remove(sink_id) + + assert conflicts == [("pr_reviewer", "num_max_findings", "services/billing")] + assert any("pr_reviewer.num_max_findings" in line for line in captured_lines) + + def test_sibling_conflict_ignores_disjoint_keys(self): + ordered = ["services/auth", "services/billing"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": b"[pr_description]\nuse_description_markers = true\n", + } + + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + + assert conflicts == [] + + def test_sibling_key_shared_by_two_of_three_is_warned(self): + from loguru import logger as loguru_logger + + ordered = ["services/auth", "services/billing", "services/orders"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + "services/orders/.pr_agent.toml": b"[pr_description]\nuse_description_markers = true\n", + } + + captured_lines = [] + sink_id = loguru_logger.add( + lambda msg: captured_lines.append(str(msg)), + level="WARNING", + ) + try: + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + finally: + loguru_logger.remove(sink_id) + + # pr_reviewer.num_max_findings is set by auth + billing only; orders only sets config.temperature. + assert conflicts == [("pr_reviewer", "num_max_findings", "services/billing")] + assert any("pr_reviewer.num_max_findings" in line for line in captured_lines) + + def test_sibling_winner_is_last_owner_that_sets_key(self): + ordered = ["services/auth", "services/billing", "services/orders"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + # orders is the last sibling in path order but its content is invalid TOML, + # so it can never be the winner for a key it does not set. + "services/orders/.pr_agent.toml": b"[pr_reviewer\nbroken = true\n", + } + + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + + assert conflicts == [("pr_reviewer", "num_max_findings", "services/billing")] + + def test_no_config_crossed_returns_empty(self, per_dir_settings): + provider = _provider( + tree_paths=["services/auth/.pr_agent.toml"], + contents={"services/auth/.pr_agent.toml": SERVICES_AUTH_TOML}, + files=["docs/readme.md"], + ) + + assert git_utils._get_per_directory_settings(provider) == [] + + def test_cap_keeps_shallowest_configs(self, per_dir_settings): + get_settings().config.per_directory_settings_max_files = 2 + provider = _provider( + tree_paths=[ + "svc1/.pr_agent.toml", + "svc1/deep/.pr_agent.toml", + "svc2/.pr_agent.toml", + ], + contents={ + "svc1/.pr_agent.toml": SERVICES_TOML, + "svc1/deep/.pr_agent.toml": SERVICES_AUTH_TOML, + "svc2/.pr_agent.toml": SERVICES_BILLING_TOML, + }, + files=["svc1/deep/api.py", "svc2/x.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # Shallowest first: the deeper svc1/deep config is dropped once the cap is full. + assert [path for path, _ in resolved] == [ + "svc1/.pr_agent.toml", + "svc2/.pr_agent.toml", + ] + + def test_cap_cutting_an_equal_depth_group_keeps_laterpath_winners(self, per_dir_settings): + get_settings().config.per_directory_settings_max_files = 2 + provider = _provider( + tree_paths=[ + "svc1/.pr_agent.toml", + "svc2/.pr_agent.toml", + "svc3/.pr_agent.toml", + ], + contents={ + "svc1/.pr_agent.toml": SERVICES_TOML, + "svc2/.pr_agent.toml": SERVICES_BILLING_TOML, + "svc3/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["svc1/x.py", "svc2/y.py", "svc3/z.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # The cap cuts through an equal-depth group: the lexicographically-later (winning) + # siblings are retained and svc3 still participates as the documented winner. + assert [path for path, _ in resolved] == [ + "svc2/.pr_agent.toml", + "svc3/.pr_agent.toml", + ] + + def test_per_directory_oversized_config_is_dropped(self, per_dir_settings, monkeypatch): + monkeypatch.setattr(git_utils, "MAX_TOML_SIZE_IN_BYTES", 100) + provider = _provider( + tree_paths=["svc1/.pr_agent.toml", "svc2/.pr_agent.toml"], + contents={ + "svc1/.pr_agent.toml": b"[config]\n" + b"#" * 200, + "svc2/.pr_agent.toml": SERVICES_TOML, + }, + files=["svc1/x.py", "svc2/y.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # An over-limit nested file is dropped before retention or parsing; the valid one stays. + assert [path for path, _ in resolved] == ["svc2/.pr_agent.toml"] + + def test_per_directory_aggregate_budget_stops_retention(self, per_dir_settings, monkeypatch): + monkeypatch.setattr( + git_utils, "_PER_DIRECTORY_SETTINGS_AGGREGATE_BYTES", len(SERVICES_TOML) + 10 + ) + provider = _provider( + tree_paths=[ + "svc1/.pr_agent.toml", + "svc2/.pr_agent.toml", + "svc3/.pr_agent.toml", + ], + contents={ + "svc1/.pr_agent.toml": SERVICES_TOML, + "svc2/.pr_agent.toml": SERVICES_BILLING_TOML, + "svc3/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["svc1/x.py", "svc2/y.py", "svc3/z.py"], + ) + + reserved = git_utils._get_per_directory_settings(provider) + + # Once the aggregate budget is exhausted, further files are not retained. + assert [path for path, _ in reserved] == ["svc1/.pr_agent.toml"] + + def test_sibling_conflict_parse_skips_oversized_files(self, per_dir_settings, monkeypatch): + monkeypatch.setattr(git_utils, "MAX_TOML_SIZE_IN_BYTES", 100) + oversized = b"[pr_reviewer]\nnum_max_findings = 10\n" + b"#" * 300 + contents = { + "svc1/.pr_agent.toml": oversized, + "svc2/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 5\n", + } + + conflicts = git_utils._warn_on_sibling_key_conflicts(["svc1", "svc2"], contents) + + # The oversized sibling is not parsed, so no conflict with svc2 can be reported. + assert conflicts == [] + + def test_disabled_returns_empty_without_any_provider_call(self, fresh_global_settings): + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + provider = _provider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=["services/api.py"], + ) + + assert git_utils._get_per_directory_settings(provider) == [] + assert provider.tree_calls == 0 + assert provider.get_files_calls == 0 + + def test_provider_without_support_is_inert(self, per_dir_settings): + class BareProvider: + def __init__(self): + self.get_files_calls = 0 + + def get_repo_settings(self): + return b"" + + def get_files(self): + self.get_files_calls += 1 + return ["services/api.py"] + + provider = BareProvider() + assert git_utils._get_per_directory_settings(provider) == [] + assert provider.get_files_calls == 0 + + def test_changed_file_path_normalization(self, per_dir_settings): + provider = _provider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=[ + "services/plain.py", + {"new_path": "services/dict_new.py"}, + {"filename": "services/dict_filename.py"}, + SimpleNamespace(filename="services/obj_filename.py"), + ], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + assert [path for path, _ in resolved] == ["services/.pr_agent.toml"] + + def test_changed_file_paths_include_rename_metadata(self, per_dir_settings): + provider = _provider( + tree_paths=["legacy/.pr_agent.toml", "services/.pr_agent.toml"], + contents={ + "legacy/.pr_agent.toml": SERVICES_TOML, + "services/.pr_agent.toml": SERVICES_TOML, + }, + files=[ + {"new_path": "services/api.py", "old_path": "legacy/api.py"}, + SimpleNamespace(new_path="services/web.py", previous_filename="legacy/web.py"), + {"new_path": "services/fresh.py", "old_path": None}, + ], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # Both the source and the destination side of each move contribute paths, + # so their ancestor configs apply; a bare new file contributes only its own. + assert {path for path, _ in resolved} == { + "legacy/.pr_agent.toml", + "services/.pr_agent.toml", + } + + def test_incremental_state_does_not_shrink_discovery(self, per_dir_settings): + provider = _provider( + tree_paths=["services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["services/api.py"], + full_files=["services/api.py", "services/auth/other.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # get_files() narrows to the unreviewed subset once an incremental review + # is active; discovery must use the complete PR file set instead, so a later + # command in the same request does not silently drop per-directory configs. + assert {path for path, _ in resolved} == { + "services/.pr_agent.toml", + "services/auth/.pr_agent.toml", + } + assert provider.get_files_calls == 0 + assert provider.pr_file_paths_calls == 1 + + def test_config_branch_passed_to_tree(self, per_dir_settings): + get_settings().set("CONFIG.CONFIG_BRANCH", "cfg-branch") + provider = _provider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=["services/api.py"], + ) + + git_utils._get_per_directory_settings(provider) + + assert provider.tree_refs == ["cfg-branch"] + + def test_get_files_failure_degrades_to_empty(self, per_dir_settings): + class ExplodingProvider(FakePerDirProvider): + def get_pr_file_paths(self): + raise RuntimeError("boom") + + provider = ExplodingProvider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + ) + + assert git_utils._get_per_directory_settings(provider) == [] + + def test_tree_failure_degrades_to_empty(self, per_dir_settings): + class ExplodingProvider(FakePerDirProvider): + def get_repo_settings_tree(self, ref): + raise RuntimeError("boom") + + provider = ExplodingProvider(files=["services/api.py"]) + + assert git_utils._get_per_directory_settings(provider) == [] + + +class TestApplyPerDirectorySettings: + def test_merge_root_then_directory_nearest_wins(self, per_dir_settings, monkeypatch): + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["services/auth/api.py", "services/billing/x.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Nearest (services/auth) wins over services, which wins over root. + assert get_settings().pr_reviewer.num_max_findings == 3 + assert get_settings().config.temperature == 0.5 + assert get_settings().config.model == "auth-model" + + def test_different_key_casing_overrides_root_value(self, per_dir_settings, monkeypatch): + # Dynaconf resolves keys case-insensitively, so a per-directory file that spells + # the same key with different casing must replace the root value, not join it as a + # duplicate that leaves the old value winning (regression for "nearest wins"). + nested = b""" +[pr_reviewer] +Num_Max_Findings = 4 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=b"[pr_reviewer]\nnum_max_findings = 10\n", + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": nested}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().pr_reviewer.num_max_findings == 4 + + def test_list_values_replace_not_concatenate(self, per_dir_settings, monkeypatch): + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # The default [ignore] glob ('vendor/**') must be replaced by the per-directory + # file wholesale, not concatenated with it. + assert get_settings().ignore.glob == ["*.gen.js"] + + def test_whitelist_blocks_secrets_and_critical_sections(self, per_dir_settings, monkeypatch): + evil = b""" +[openai] +api_base = "https://evil.example.com" + +[config] +model = "allowed-model" +git_provider = "gitlab" + +[push_outputs] +webhook_url = "https://evil.example.com/hook" + +[pr_reviewer] +num_max_findings = 2 +publish_error_details = true +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": evil}, + files=["services/api.py"], + ), + ) + # Baseline: ensure inherited settings from earlier tests don't mask a leak. + get_settings().config.git_provider = "github" + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # [openai], [push_outputs] and config.git_provider are not per-directory overridable. + assert get_settings().get("openai.api_base", None) != "https://evil.example.com" + assert get_settings().config.git_provider != "gitlab" + assert get_settings().get("push_outputs.webhook_url", None) != "https://evil.example.com/hook" + # Allowed keys still land, and the repo-host-only pr_reviewer key stays dropped. + assert get_settings().config.model == "allowed-model" + assert get_settings().pr_reviewer.num_max_findings == 2 + assert get_settings().pr_reviewer.publish_error_details is False + + def test_whitelist_rejects_unknown_sections_entirely(self, per_dir_settings, monkeypatch): + config = b""" +[openai] +api_base = "https://evil.example.com" + +[pr_reviewer] +num_max_findings = 4 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=b"", + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().get("openai.api_base", None) != "https://evil.example.com" + assert get_settings().pr_reviewer.num_max_findings == 4 + + def test_write_or_url_trigger_keys_are_dropped_in_per_directory(self, per_dir_settings, monkeypatch): + config = b""" +[pr_update_changelog] +push_changelog_changes = true +add_pr_link = true +extra_instructions = "keep-me" + +[pr_help_docs] +repo_url = "https://evil.example.com/steal" +docs_path = "custom-docs" +exclude_root_readme = true +supported_doc_exts = [".md"] +enable_help_text = true +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().pr_update_changelog.push_changelog_changes is False + assert get_settings().get("pr_help_docs.repo_url", "") == "" + # Collection scope is root-/host-controlled: a nested file cannot point /help_docs + # at arbitrary repository paths or file extensions to read into the model prompt. + assert get_settings().pr_help_docs.docs_path == "docs" + assert get_settings().pr_help_docs.supported_doc_exts == [".md", ".mdx", ".rst"] + assert get_settings().pr_update_changelog.add_pr_link is True + assert get_settings().pr_update_changelog.extra_instructions == "keep-me" + assert get_settings().pr_help_docs.exclude_root_readme is True + assert get_settings().pr_help_docs.enable_help_text is True + + def test_description_questions_and_similar_issue_host_only_keys_are_dropped( + self, per_dir_settings, monkeypatch + ): + config = b""" +[pr_reviewer] +num_max_findings = 4 +enable_large_pr_chunking = true +max_number_of_calls = 9999 +inline_key_issues = true +enable_review_labels_security = false +enable_review_labels_effort = false +require_security_review = false +require_estimate_effort_to_review = false +require_ticket_analysis_review = false + +[pr_description] +publish_labels = true +use_ai_title = true +generate_ai_title = true +publish_description_as_comment = true +publish_description_as_comment_persistent = false +enable_large_pr_handling = true +max_ai_calls = 400 +async_ai_calls = false + +[pr_questions] +resolve_threads = true +static_questions = ["default"] +use_conversation_history = true + +[pr_code_suggestions] +commitable_code_suggestions = true +num_code_suggestions_per_chunk = 2 +max_number_of_calls = 99 +parallel_calls = true +demand_code_suggestions_self_review = true +approve_pr_on_self_review = true + +[pr_similar_issue] +force_update_dataset = true +skip_comments = false +max_issues_to_scan = 999999 +vectordb = "pinecone" +use_original_title = false +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML + b""" +[pr_questions] +use_conversation_history = false +[pr_similar_issue] +skip_comments = true +""", + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Label mutation, thread resolution and full issue-index refresh switches are + # host-/root-controlled and must stay at their trusted defaults. + assert get_settings().pr_description.publish_labels is False + assert get_settings().pr_questions.resolve_threads is False + assert get_settings().pr_similar_issue.force_update_dataset is False + assert get_settings().pr_similar_issue.skip_comments is True + assert get_settings().get("pr_similar_issue.max_issues_to_scan", 0) != 999999 + assert get_settings().pr_similar_issue.vectordb != "pinecone" + # Pull-request metadata and reviewer label/inline controls stay trusted: + # a nested file cannot rewrite PR titles/bodies, publish reviewer effort or + # security labels, or open inline key-issue comments through the bot identity. + assert get_settings().pr_description.generate_ai_title is False + assert get_settings().pr_description.publish_description_as_comment is False + assert get_settings().pr_description.publish_description_as_comment_persistent is True + assert get_settings().pr_reviewer.inline_key_issues is False + assert get_settings().pr_reviewer.enable_review_labels_security is True + assert get_settings().pr_reviewer.enable_review_labels_effort is True + assert get_settings().pr_reviewer.require_security_review is True + assert get_settings().pr_reviewer.require_estimate_effort_to_review is True + assert get_settings().pr_reviewer.require_ticket_analysis_review is True + # Budget/call-count controls stay at the host-trusted values: nested files must + # not multiply AI calls on their own. + assert get_settings().pr_reviewer.enable_large_pr_chunking is False + assert get_settings().pr_reviewer.max_number_of_calls != 9999 + assert get_settings().pr_description.enable_large_pr_handling != 999999 + assert get_settings().pr_description.max_ai_calls != 400 + assert get_settings().pr_description.async_ai_calls is True + assert get_settings().pr_code_suggestions.max_number_of_calls != 99 + assert get_settings().pr_code_suggestions.parallel_calls is True + # The self-review approval workflow stays root-controlled: a nested file cannot + # demand a self-review checklist and then auto-approve on the author's tick. + assert get_settings().pr_code_suggestions.demand_code_suggestions_self_review is False + assert get_settings().pr_code_suggestions.approve_pr_on_self_review is False + # Thread-history collection for /ask is root-controlled: a nested file cannot + # re-enable sending private review-thread discussion bodies to the model. + assert get_settings().pr_questions.use_conversation_history is False + # Ordinary keys in the same sections still apply. + assert get_settings().pr_reviewer.num_max_findings == 4 + assert get_settings().pr_description.use_ai_title is True + assert get_settings().pr_questions.static_questions == ["default"] + assert get_settings().pr_code_suggestions.commitable_code_suggestions is False + assert get_settings().pr_code_suggestions.num_code_suggestions_per_chunk == 2 + assert get_settings().pr_similar_issue.use_original_title is False + + def test_per_directory_ignore_allows_only_glob(self, per_dir_settings, monkeypatch): + config = b""" +[ignore] +glob = ["gen/**"] +regex = ["(a+)+$"] +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Bounded glob patterns land; arbitrary regexes (which filter_ignored() compiles + # and matches on every review) stay at the root default so a nested file cannot + # commit a catastrophic-backtracking pattern. + assert get_settings().ignore.glob == ["gen/**"] + assert get_settings().ignore.regex == [] + + def test_per_directory_config_drops_unbounded_repo_context_knobs(self, per_dir_settings, monkeypatch): + config = b""" +[config] +model = "nested-model" +temperature = 0.5 +repo_context_files = ["huge.bin", "secrets.env", "vendor/data.bin"] +repo_context_max_lines = 9999999 +model_token_count_estimate_factor = -0.999999 +per_directory_settings_max_tree_pages = 999999 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Context-fetch knobs are root-/host-controlled; a nested file cannot make tools + # fetch arbitrary repo files in full when they build context for its directory. + assert get_settings().get("config.repo_context_files", []) != [ + "huge.bin", + "secrets.env", + "vendor/data.bin", + ] + assert get_settings().get("config.repo_context_max_lines", 0) != 9999999 + assert get_settings().config.model_token_count_estimate_factor == 0.3 + assert get_settings().config.per_directory_settings_max_tree_pages == 10 + # Model-routing and output knobs still apply. + assert get_settings().config.model == "nested-model" + assert get_settings().config.temperature == 0.5 + + def test_per_directory_config_drops_non_string_model_values(self, per_dir_settings, monkeypatch): + trusted_model_weak = get_settings().get("config.model_weak", None) + config = b""" +[config] +model = 1 +model_weak = false +model_reasoning = "reasoning-model" +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().config.model == "root-model" + assert get_settings().get("config.model_weak", None) == trusted_model_weak + assert get_settings().config.model_reasoning == "reasoning-model" + + @pytest.mark.parametrize("invalid_value", [(1, "1"), (False, "false"), (["fr-fr"], '["fr-fr"]'), + ({"locale": "fr-fr"}, '{locale = "fr-fr"}')]) + def test_per_directory_config_drops_non_string_response_language(self, per_dir_settings, monkeypatch, + invalid_value): + _, toml_value = invalid_value + config = f"[config]\nresponse_language = {toml_value}\n".encode() + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().get("config.response_language", "en-us") == "en-US" + + @pytest.mark.parametrize("toml_value", [ + '"hot"', "true", "[0.1]", '{value = "0.1"}', "nan", "-0.1", "2.1", + ]) + def test_per_directory_config_drops_invalid_temperature(self, per_dir_settings, monkeypatch, toml_value): + config = f"[config]\ntemperature = {toml_value}\n".encode() + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().config.temperature == 0.1 + + @pytest.mark.parametrize("toml_value", ['"six"', "true", "-1", "1001", "1.5"]) + def test_per_directory_config_drops_invalid_description_threshold(self, per_dir_settings, monkeypatch, + toml_value): + config = f"[pr_description]\ncollapsible_file_list_threshold = {toml_value}\n".encode() + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().get("pr_description.collapsible_file_list_threshold") == 6 + + def test_per_directory_config_drops_fallback_models(self, per_dir_settings, monkeypatch): + config = b""" +[config] +model = "nested-model" +temperature = 0.5 +fallback_models = ["m-one", "m-two", "m-three", "m-four", "m-five"] +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Fallback routing stays root-/host-controlled: the retry helper turns each + # entry into one more routed attempt per failing model, so a nested file must + # not be able to multiply AI calls with an arbitrarily long list. + assert list(get_settings().config.fallback_models) == ["gpt-5.6-terra"] + # Other model-routing and output knobs still apply. + assert get_settings().config.model == "nested-model" + assert get_settings().config.temperature == 0.5 + + def test_per_directory_config_drops_token_budget_keys(self, per_dir_settings, monkeypatch): + config = b""" +[config] +model = "nested-model" +temperature = 0.5 +max_model_tokens = 999999 +custom_model_max_tokens = 999999 +max_output_tokens = 999999 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Token budgets are root-/host-controlled because they directly size request + # and completion limits; a nested file cannot inflate the cost of every call. + assert get_settings().config.max_model_tokens != 999999 + assert get_settings().config.custom_model_max_tokens != 999999 + assert get_settings().config.max_output_tokens != 999999 + # Model-routing and output knobs still apply. + assert get_settings().config.model == "nested-model" + assert get_settings().config.temperature == 0.5 + + def test_malformed_per_directory_config_reports_error(self, per_dir_settings, monkeypatch): + malformed = b"[pr_reviewer\nnum_max_findings = 2\n" + provider = _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": malformed}, + files=["services/api.py"], + ) + comments = [] + provider.publish_persistent_comment = lambda *args, **kwargs: comments.append((args, kwargs)) + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: provider, + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert len(comments) == 1 + assert "services/.pr_agent.toml" in comments[0][0][0] + # The root config still applied before the malformed per-directory file. + assert get_settings().pr_reviewer.num_max_findings == 10 + + def test_per_directory_inert_when_feature_disabled(self, fresh_global_settings, monkeypatch): + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + provider = _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_AUTH_TOML}, + files=["services/auth/api.py"], + ) + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: provider, + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert provider.tree_calls == 0 + assert provider.get_files_calls == 0 + assert get_settings().pr_reviewer.num_max_findings == 10 + + +def _github_provider(repo_obj): + provider = GithubProvider.__new__(GithubProvider) + provider.repo_obj = repo_obj + provider._resolved_config_branch = None + return provider + + +class TestGithubProviderPerDirectory: + def test_get_repo_settings_tree_filters_pr_agent_toml_blobs(self): + repo_obj = MagicMock() + repo_obj.default_branch = "main" + repo_obj.get_git_tree.return_value = SimpleNamespace(tree=[ + SimpleNamespace(path=".pr_agent.toml", type="blob"), + SimpleNamespace(path="services/auth/.pr_agent.toml", type="blob"), + SimpleNamespace(path="services/auth/code.py", type="blob"), + SimpleNamespace(path="services/auth", type="tree"), + ]) + provider = _github_provider(repo_obj) + + paths, resolved_ref = provider.get_repo_settings_tree() + + assert resolved_ref == "main" + assert paths == [".pr_agent.toml", "services/auth/.pr_agent.toml"] + repo_obj.get_git_tree.assert_called_once_with("main", recursive=True) + + def test_get_repo_settings_tree_uses_resolved_config_branch(self): + repo_obj = MagicMock() + repo_obj.get_git_tree.return_value = SimpleNamespace(tree=[]) + provider = _github_provider(repo_obj) + provider._resolved_config_branch = "cfg-branch" + + _, resolved_ref = provider.get_repo_settings_tree("") + + assert resolved_ref == "cfg-branch" + repo_obj.get_git_tree.assert_called_once_with("cfg-branch", recursive=True) + + def test_resolved_config_branch_beats_explicit_ref(self): + # get_repo_settings() stores the branch it actually read the root config + # from (already fallback-resolved), so it must win over a CONFIG_BRANCH hint: + # when that branch exists without a root .pr_agent.toml the tree must follow + # the root config onto the default branch instead of reading a stale branch. + repo_obj = MagicMock() + repo_obj.get_git_tree.return_value = SimpleNamespace(tree=[], truncated=False) + provider = _github_provider(repo_obj) + provider._resolved_config_branch = "resolved-default" + + _, resolved_ref = provider.get_repo_settings_tree("stale-config-branch") + + assert resolved_ref == "resolved-default" + repo_obj.get_git_tree.assert_called_once_with("resolved-default", recursive=True) + + def test_truncated_tree_skips_per_directory_settings(self): + from loguru import logger as loguru_logger + + repo_obj = MagicMock() + repo_obj.get_git_tree.return_value = SimpleNamespace( + tree=[SimpleNamespace(path="svc/.pr_agent.toml", type="blob")], + truncated=True, + ) + provider = _github_provider(repo_obj) + + captured_lines = [] + sink_id = loguru_logger.add( + lambda msg: captured_lines.append(str(msg)), + level="WARNING", + ) + try: + paths, resolved_ref = provider.get_repo_settings_tree("big-branch") + finally: + loguru_logger.remove(sink_id) + + assert paths == [] + assert resolved_ref == "big-branch" + assert any("truncated" in line for line in captured_lines) + + def test_get_repo_settings_tree_falls_back_to_default_on_404(self): + repo_obj = MagicMock() + repo_obj.default_branch = "main" + repo_obj.get_git_tree.side_effect = [ + GithubException(404, {"message": "Not Found"}, None), + SimpleNamespace(tree=[ + SimpleNamespace(path="svc/.pr_agent.toml", type="blob"), + ]), + ] + provider = _github_provider(repo_obj) + + paths, resolved_ref = provider.get_repo_settings_tree("missing-branch") + + assert resolved_ref == "main" + assert paths == ["svc/.pr_agent.toml"] + assert repo_obj.get_git_tree.call_args_list == [call("missing-branch", recursive=True), call("main", recursive=True)] + + def test_get_repo_settings_tree_surfaces_unexpected_errors(self): + repo_obj = MagicMock() + repo_obj.get_git_tree.side_effect = GithubException(403, {"message": "Forbidden"}, None) + provider = _github_provider(repo_obj) + + with pytest.raises(GithubException): + provider.get_repo_settings_tree("") + + def test_get_repo_settings_contents(self): + repo_obj = MagicMock() + repo_obj.get_contents.return_value = SimpleNamespace(decoded_content=b"[pr_reviewer]\nnum_max_findings = 5\n") + provider = _github_provider(repo_obj) + + result = provider.get_repo_settings_contents(["services/auth/.pr_agent.toml"], "main") + + assert result == {"services/auth/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 5\n"} + repo_obj.get_contents.assert_called_once_with("services/auth/.pr_agent.toml", ref="main") + + def test_get_repo_settings_contents_skips_missing_file(self): + repo_obj = MagicMock() + repo_obj.get_contents.side_effect = GithubException(404, {"message": "Not Found"}, None) + provider = _github_provider(repo_obj) + + result = provider.get_repo_settings_contents(["services/.pr_agent.toml"], "main") + + assert result == {} + + def test_get_pr_file_paths_returns_full_set_with_rename_metadata(self): + full_files = [ + SimpleNamespace(filename="services/api.py", previous_filename="legacy/api.py"), + SimpleNamespace(filename="services/fresh.py", previous_filename=None), + ] + provider = _github_provider(MagicMock()) + provider.git_files = full_files + provider.incremental = SimpleNamespace(is_incremental=True) + provider.unreviewed_files_map = {"services/api.py": "bogus-subset"} + + result = provider.get_pr_file_paths() + + # Even with an incremental review active (unreviewed_files_map populated), the + # complete PR file set with rename metadata is returned, never the reviewed subset. + assert result == full_files + assert [entry.previous_filename for entry in result] == ["legacy/api.py", None] + + +def _gitlab_provider(gl, id_project="1"): + provider = GitLabProvider.__new__(GitLabProvider) + provider.gl = gl + provider.id_project = id_project + return provider + + +class TestGitLabProviderPerDirectory: + def test_get_repo_settings_tree_filters_blobs_on_default_branch(self): + project = MagicMock() + project.default_branch = "main" + project.repository_tree.return_value = [ + {"path": ".pr_agent.toml", "type": "blob"}, + {"path": "svc/.pr_agent.toml", "type": "blob"}, + {"path": "svc/code.py", "type": "blob"}, + {"path": "svc", "type": "tree"}, + ] + gl = MagicMock() + gl.projects.get.return_value = project + provider = _gitlab_provider(gl) + + paths, resolved_ref = provider.get_repo_settings_tree("ignored-ref") + + assert resolved_ref == "main" + assert paths == [".pr_agent.toml", "svc/.pr_agent.toml"] + project.repository_tree.assert_called_once_with(ref="main", recursive=True, page=1, per_page=100) + + @pytest.mark.parametrize("complete", [True, False]) + def test_tree_discovery_is_bounded_and_requires_complete_results(self, per_dir_settings, complete): + get_settings().config.per_directory_settings_max_tree_pages = 2 + project = MagicMock() + project.default_branch = "main" + full_page = [{"path": "svc/.pr_agent.toml", "type": "blob"}] * 100 + project.repository_tree.side_effect = [full_page, [] if complete else full_page] + gl = MagicMock() + gl.projects.get.return_value = project + + paths, ref = _gitlab_provider(gl).get_repo_settings_tree() + + assert ref == "main" + assert paths == (["svc/.pr_agent.toml"] * 100 if complete else []) + assert project.repository_tree.call_args_list == [ + call(ref="main", recursive=True, page=1, per_page=100), + call(ref="main", recursive=True, page=2, per_page=100), + ] + + def test_get_repo_settings_contents(self): + project = MagicMock() + file_obj = MagicMock() + file_obj.decode.return_value = b"[pr_reviewer]\nnum_max_findings = 5\n" + project.files.get.return_value = file_obj + gl = MagicMock() + gl.projects.get.return_value = project + provider = _gitlab_provider(gl) + + result = provider.get_repo_settings_contents(["svc/.pr_agent.toml"], "main") + + assert result == {"svc/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 5\n"} + project.files.get.assert_called_once_with(file_path="svc/.pr_agent.toml", ref="main") + + def test_get_repo_settings_contents_skips_missing_file(self): + from gitlab.exceptions import GitlabGetError + + project = MagicMock() + project.files.get.side_effect = GitlabGetError(response_code=404) + gl = MagicMock() + gl.projects.get.return_value = project + provider = _gitlab_provider(gl) + + result = provider.get_repo_settings_contents(["svc/.pr_agent.toml"], "main") + + assert result == {} + + def test_get_pr_file_paths_returns_full_changes_with_rename_metadata(self): + provider = _gitlab_provider(MagicMock()) + changes = [ + {"new_path": "services/api.py", "old_path": "legacy/api.py"}, + {"new_path": "services/fresh.py", "old_path": "services/fresh.py"}, + ] + provider._get_merge_request_changes = MagicMock(return_value={"changes": changes}) + provider._expand_submodule_changes = lambda ch: ch + + result = provider.get_pr_file_paths() + + # Both sides of a rename survive, independent of incremental review state. + assert result == changes + + +@pytest.mark.parametrize("next_root", [b"", b"[pr_reviewer]\nnum_max_findings = 12\n"]) +def test_directory_overrides_do_not_leak_between_commands(fresh_global_settings, monkeypatch, next_root): + settings = get_settings() + settings.config.enable_per_directory_settings = True + settings.config.use_repo_settings_file = True + settings.pr_reviewer.num_max_findings = 10 + providers = iter([ + _provider( + tree_paths=["services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 3\nnew_test_key = 42\n", + }, + files=["services/auth/api.py"], + ), + _provider(tree_paths=[], contents={}, files=["README.md"], root_settings=next_root), + ]) + monkeypatch.setattr(git_utils, "get_git_provider_with_context", lambda url: next(providers)) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + assert settings.pr_reviewer.num_max_findings == 3 + assert settings.pr_reviewer.new_test_key == 42 + settings.pr_reviewer.extra_instructions = "later trusted change" + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/2") + assert settings.pr_reviewer.num_max_findings == (12 if next_root else 10) + assert "new_test_key" not in settings.pr_reviewer + assert settings.pr_reviewer.extra_instructions == "later trusted change" diff --git a/tests/unittest/test_pr_help_docs_path_containment.py b/tests/unittest/test_pr_help_docs_path_containment.py new file mode 100644 index 0000000000..43007102c1 --- /dev/null +++ b/tests/unittest/test_pr_help_docs_path_containment.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace + +from pr_agent.tools.pr_help_docs import PRHelpDocs + + +def _help_docs_with_clone(tmp_path, docs_path, include_root_readme_file=False): + clone_root = tmp_path / "clone" + clone_root.mkdir(parents=True, exist_ok=True) + (clone_root / "docs" / "guide.md").parent.mkdir(parents=True, exist_ok=True) + (clone_root / "docs" / "guide.md").write_text("docs content", encoding="utf-8") + provider = PRHelpDocs.__new__(PRHelpDocs) + provider.repo_url = "https://github.com/org/repo" + provider.ctx_url = "https://github.com/org/repo/pull/1" + provider.include_root_readme_file = include_root_readme_file + provider.supported_doc_exts = [".md"] + provider.docs_path = docs_path + provider.git_provider = SimpleNamespace(clone=lambda url, dst, remove_dest_folder: SimpleNamespace( + path=str(clone_root))) + return provider + + +def test_docs_path_within_clone_is_read(tmp_path): + provider = _help_docs_with_clone(tmp_path, "docs") + result = provider._gen_filenames_to_contents_map_from_repo() + assert result and "docs content" in next(iter(result.values())) + + +def test_absolute_docs_path_escaping_clone_is_rejected(tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.md").write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, str(outside)) + assert provider._gen_filenames_to_contents_map_from_repo() == {} + + +def test_traversing_docs_path_escaping_clone_is_rejected(tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.md").write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, "../outside") + assert provider._gen_filenames_to_contents_map_from_repo() == {} + + +def test_symlinked_doc_file_escaping_clone_is_skipped(tmp_path): + secret = tmp_path / "outside" / "secret.md" + secret.parent.mkdir(parents=True, exist_ok=True) + secret.write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, "docs") + (tmp_path / "clone" / "docs" / "leak.md").symlink_to(secret) + + result = provider._gen_filenames_to_contents_map_from_repo() + + assert any("docs content" in v for v in result.values()) + assert not any("HOST SECRET" in v for v in result.values()) + + +def test_symlinked_root_readme_escaping_clone_is_skipped(tmp_path): + secret = tmp_path / "outside" / "secret.md" + secret.parent.mkdir(parents=True, exist_ok=True) + secret.write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, "docs", include_root_readme_file=True) + clone_root = tmp_path / "clone" + (clone_root / "readme.txt").write_text("clone readme", encoding="utf-8") + (clone_root / "README.md").symlink_to(secret) + + result = provider._gen_filenames_to_contents_map_from_repo() + + assert any("clone readme" in v for v in result.values()) + assert not any("HOST SECRET" in v for v in result.values()) + + +def test_symlink_to_in_clone_file_is_kept(tmp_path): + provider = _help_docs_with_clone(tmp_path, "docs") + clone_root = tmp_path / "clone" + (clone_root / "docs" / "guide.md").write_text("original content", encoding="utf-8") + (clone_root / "docs" / "alias.md").symlink_to(clone_root / "docs" / "guide.md") + + result = provider._gen_filenames_to_contents_map_from_repo() + + assert sum("original content" in v for v in result.values()) >= 1 From aa7f5d3e45358bb405441facab9b3dd54e25b46e Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Mon, 14 Sep 2026 07:54:44 -0500 Subject: [PATCH 097/238] fix(cli): report propagated request failures (#3368) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> Co-authored-by: IsmaelMartinez --- docs/docs/installation/locally.md | 6 +- .../docs/usage-guide/automations_and_usage.md | 8 + pr_agent/cli.py | 17 +- pr_agent/cli_pip.py | 4 +- tests/unittest/test_cli_artifact_context.py | 2 +- tests/unittest/test_cli_config_branch.py | 4 + tests/unittest/test_cli_exit_status.py | 431 ++++++++++++++++++ 7 files changed, 462 insertions(+), 10 deletions(-) create mode 100644 tests/unittest/test_cli_exit_status.py diff --git a/docs/docs/installation/locally.md b/docs/docs/installation/locally.md index 3dc7da908e..c4fb9e6f2f 100644 --- a/docs/docs/installation/locally.md +++ b/docs/docs/installation/locally.md @@ -122,13 +122,15 @@ def main(): get_settings().set("github.user_token", user_token) # Run the command. Feedback will appear in GitHub PR comments - cli.run_command(pr_url, command) + return cli.run_command(pr_url, command) if __name__ == '__main__': - main() + raise SystemExit(main()) ``` +With `config.propagate_tool_errors` enabled, forwarding the return value through `SystemExit` makes this script exit with status 1 after a propagated tool error. The default remains status 0. + ## Run from source 1. Clone this repository: diff --git a/docs/docs/usage-guide/automations_and_usage.md b/docs/docs/usage-guide/automations_and_usage.md index bed8ee4d8a..d674f857f5 100644 --- a/docs/docs/usage-guide/automations_and_usage.md +++ b/docs/docs/usage-guide/automations_and_usage.md @@ -34,6 +34,14 @@ This is useful for debugging or experimenting with different tools. 3. **git provider**: The [git_provider](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) field in a configuration file determines the GIT provider that will be used by PR-Agent. Currently, the following providers are supported: `github` **(default)**, `gitlab`, `bitbucket`, `azure`, `codecommit`, `local`, and `gitea`. +4. For scripts that need a failed request to return a non-zero process status, enable tool error propagation: + +```bash +python -m pr_agent.cli --pr_url= review --config.propagate_tool_errors=true +``` + +When a propagated tool error makes the request fail, the installed `pr-agent` command, `python -m pr_agent.cli`, and the customizable pip script exit with status 1. The default remains compatible with existing CLI behavior and exits with status 0; argparse parse and usage errors continue to exit with status 2. + ### CLI Health Check To verify that PR-Agent has been configured correctly, you can run this health check command from the repository root: diff --git a/pr_agent/cli.py b/pr_agent/cli.py index 6bf432c95f..b28524f5dc 100644 --- a/pr_agent/cli.py +++ b/pr_agent/cli.py @@ -142,7 +142,7 @@ def run_command(pr_url, command): args = set_parser().parse_args(run_command_str.split()) # Run the command. Feedback will appear in GitHub PR comments - run(args=args) + return run(args=args) def run(inargs=None, args=None): @@ -178,6 +178,8 @@ def run(inargs=None, args=None): return command = args.command.lower() + settings = get_settings() + propagate_tool_errors_before = settings.config.get("propagate_tool_errors", False) get_settings().set("CONFIG.CLI_MODE", True) # Strip each candidate independently so a whitespace-only CLI value doesn't # short-circuit the PR_AGENT_CONFIG_BRANCH env fallback before precedence. @@ -213,10 +215,15 @@ async def inner(): return result - result = asyncio.run(inner()) - if not result: - parser.print_help() + try: + result = asyncio.run(inner()) + if not result: + parser.print_help() + if result is False and settings.config.get("propagate_tool_errors", False): + return 1 + finally: + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", propagate_tool_errors_before) if __name__ == '__main__': - run() + raise SystemExit(run()) diff --git a/pr_agent/cli_pip.py b/pr_agent/cli_pip.py index caa56f0c9b..db6670ab08 100644 --- a/pr_agent/cli_pip.py +++ b/pr_agent/cli_pip.py @@ -16,8 +16,8 @@ def main(): get_settings().set("github.user_token", user_token) # Run the command. Feedback will appear in GitHub PR comments - cli.run_command(pr_url, command) + return cli.run_command(pr_url, command) if __name__ == '__main__': - main() + raise SystemExit(main()) diff --git a/tests/unittest/test_cli_artifact_context.py b/tests/unittest/test_cli_artifact_context.py index 982807a076..50d6322bb0 100644 --- a/tests/unittest/test_cli_artifact_context.py +++ b/tests/unittest/test_cli_artifact_context.py @@ -7,7 +7,7 @@ def test_run_injects_the_artifact_context_before_handling_the_request(): """A pipeline that runs the CLI gets the same [artifacts] injection as the GitHub Action.""" order = [] - fake_settings = SimpleNamespace(litellm={}, set=MagicMock()) + fake_settings = SimpleNamespace(config={}, litellm={}, set=MagicMock()) async def fake_handle_request(*_args, **_kwargs): order.append("handle_request") diff --git a/tests/unittest/test_cli_config_branch.py b/tests/unittest/test_cli_config_branch.py index c3f0e09b69..e04a511433 100644 --- a/tests/unittest/test_cli_config_branch.py +++ b/tests/unittest/test_cli_config_branch.py @@ -11,6 +11,7 @@ def test_set_parser_supports_config_branch_flag(): def test_run_sets_config_branch_from_cli_flag(): fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) @@ -29,6 +30,7 @@ async def fake_handle_request(*_args, **_kwargs): def test_run_sets_config_branch_from_env_var(): fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) @@ -51,6 +53,7 @@ async def fake_handle_request(*_args, **_kwargs): def test_run_whitespace_cli_branch_falls_back_to_env_var(): """A whitespace-only --config-branch must not short-circuit the env fallback.""" fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) @@ -74,6 +77,7 @@ def test_run_reconciles_config_branch_when_absent(): """Without a flag or env var, CONFIG.CONFIG_BRANCH must be reset (not leaked from a previous run() call in the same process-wide settings singleton).""" fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) diff --git a/tests/unittest/test_cli_exit_status.py b/tests/unittest/test_cli_exit_status.py new file mode 100644 index 0000000000..199e0e762e --- /dev/null +++ b/tests/unittest/test_cli_exit_status.py @@ -0,0 +1,431 @@ +import os +import subprocess +import sys +import tomllib +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent import cli +from pr_agent.config_loader import get_settings + +_CONSOLE_SCRIPT_FALLBACK = ( + "import sys; from pr_agent.cli import run; sys.exit(run())" +) +_SETTINGS_KEYS = [ + "config.propagate_tool_errors", + "config.cli_mode", + "config.config_branch", + "config.extra_config_url", +] + + +@pytest.fixture(autouse=True) +def restore_cli_settings(): + settings = get_settings() + original = {key: settings.get(key, None) for key in _SETTINGS_KEYS} + yield + for key, value in original.items(): + settings.set(key, value) + + +def _run_with_result(monkeypatch, result, *, propagate_tool_errors): + fake_settings = SimpleNamespace( + config={"propagate_tool_errors": propagate_tool_errors}, + litellm={}, + set=MagicMock(), + ) + + async def fake_handle_request(*_args, **_kwargs): + return result + + monkeypatch.setattr(cli, "get_settings", lambda: fake_settings) + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + + return cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) + + +@pytest.mark.parametrize( + ("request_result", "propagate_tool_errors", "expected_status", "prints_help"), + [ + (True, False, None, False), + (True, True, None, False), + (False, False, None, True), + (False, True, 1, True), + (None, True, None, True), + (0, True, None, True), + ], +) +def test_run_maps_request_result_to_status( + monkeypatch, + capsys, + request_result, + propagate_tool_errors, + expected_status, + prints_help, +): + status = _run_with_result( + monkeypatch, + request_result, + propagate_tool_errors=propagate_tool_errors, + ) + + assert status == expected_status + assert ("usage:" in capsys.readouterr().out) is prints_help + + +def test_run_reads_effective_setting_after_dispatch(monkeypatch): + fake_settings = SimpleNamespace( + config={"propagate_tool_errors": False}, + litellm={}, + set=MagicMock(), + ) + + async def fake_handle_request(*_args, **_kwargs): + fake_settings.config["propagate_tool_errors"] = True + return False + + monkeypatch.setattr(cli, "get_settings", lambda: fake_settings) + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + + assert cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) == 1 + + +def test_run_drains_callbacks_before_returning_failure_status(monkeypatch): + events = [] + fake_settings = SimpleNamespace( + config={"propagate_tool_errors": True}, + litellm={}, + set=MagicMock(), + ) + + async def fake_handle_request(*_args, **_kwargs): + events.append("request") + return False + + async def fake_drain(*_args, **_kwargs): + events.append("drain") + + monkeypatch.setattr(cli, "get_settings", lambda: fake_settings) + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: True) + monkeypatch.setattr(cli, "drain_litellm_callbacks", fake_drain) + + status = cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) + + events.append(f"status:{status}") + assert events == ["request", "drain", "status:1"] + + +@pytest.mark.parametrize( + ("repo_value", "cli_value", "expected_status"), + [ + (False, True, 1), + (True, None, 1), + (True, False, None), + ], +) +def test_real_request_failure_uses_effective_propagation_setting( + monkeypatch, + repo_value, + cli_value, + expected_status, +): + from pr_agent.agent import pr_agent as pr_agent_module + + events = [] + expected_effective_value = repo_value if cli_value is None else cli_value + + class FailingReview: + def __init__(self, *_args, **_kwargs): + pass + + async def run(self): + events.append("tool") + assert ( + get_settings().config.get("propagate_tool_errors") + is expected_effective_value + ) + raise RuntimeError("controlled tool failure") + + async def fake_drain(*_args, **_kwargs): + events.append("callbacks") + + def fake_apply_repo_settings(*_args, **_kwargs): + get_settings().set("CONFIG.PROPAGATE_TOOL_ERRORS", repo_value) + + monkeypatch.setitem(pr_agent_module.command2class, "review", FailingReview) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", fake_apply_repo_settings) + monkeypatch.setattr(pr_agent_module, "flush_telemetry", lambda: events.append("telemetry")) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: True) + monkeypatch.setattr(cli, "drain_litellm_callbacks", fake_drain) + + inargs = [ + "--pr_url=https://example.com/org/repo/pull/1", + "review", + ] + if cli_value is not None: + inargs.append(f"--config.propagate_tool_errors={str(cli_value).lower()}") + + status = cli.run(inargs=inargs) + + events.append(f"status:{status}") + assert events == [ + "tool", + "telemetry", + "callbacks", + f"status:{expected_status}", + ] + + +@pytest.mark.parametrize( + ("baseline", "override_source", "override", "first_succeeds", "expected_statuses"), + [ + (False, "repository", True, False, [1, None]), + (False, "command", True, False, [1, None]), + (True, "repository", False, False, [None, 1]), + (True, "command", False, False, [None, 1]), + (False, "repository", True, True, [None, None]), + (False, "command", True, True, [None, None]), + (True, "repository", False, True, [None, 1]), + (True, "command", False, True, [None, 1]), + ], +) +def test_run_restores_propagation_setting_between_invocations( + monkeypatch, + baseline, + override_source, + override, + first_succeeds, + expected_statuses, +): + from pr_agent.agent import pr_agent as pr_agent_module + + settings = get_settings() + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", baseline) + apply_calls = 0 + observed_values = [] + request_results = iter([first_succeeds, False]) + + class ControlledReview: + def __init__(self, *_args, **_kwargs): + pass + + async def run(self): + observed_values.append(settings.config.get("propagate_tool_errors")) + if next(request_results): + return + raise RuntimeError("controlled tool failure") + + def fake_apply_repo_settings(*_args, **_kwargs): + nonlocal apply_calls + if override_source == "repository" and apply_calls == 0: + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", override) + apply_calls += 1 + + monkeypatch.setitem(pr_agent_module.command2class, "review", ControlledReview) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", fake_apply_repo_settings) + monkeypatch.setattr(pr_agent_module, "flush_telemetry", lambda: None) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: False) + + first_inargs = ["--pr_url=https://example.com/org/repo/pull/1", "review"] + if override_source == "command": + first_inargs.append(f"--config.propagate_tool_errors={str(override).lower()}") + + statuses = [ + cli.run(inargs=first_inargs), + cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/2", "review"]), + ] + + assert observed_values == [override, baseline] + assert statuses == expected_statuses + assert settings.config.get("propagate_tool_errors") is baseline + + +def test_run_restores_propagation_setting_when_callback_drain_raises(monkeypatch): + settings = get_settings() + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", False) + + async def fake_handle_request(*_args, **_kwargs): + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", True) + return False + + async def failing_drain(*_args, **_kwargs): + raise RuntimeError("controlled callback failure") + + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: True) + monkeypatch.setattr(cli, "drain_litellm_callbacks", failing_drain) + + with pytest.raises(RuntimeError, match="controlled callback failure"): + cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) + + assert settings.config.get("propagate_tool_errors") is False + + +def _console_script_entrypoint(python_executable): + console_script = Path(python_executable).with_name("pr-agent") + if sys.platform == "win32": + console_script = console_script.with_suffix(".exe") + if console_script.is_file(): + return [str(console_script)] + return [python_executable, "-c", _CONSOLE_SCRIPT_FALLBACK] + + +def test_console_script_maps_to_cli_run(): + with (Path(__file__).parents[2] / "pyproject.toml").open("rb") as pyproject: + project = tomllib.load(pyproject)["project"] + + assert project["scripts"]["pr-agent"] == "pr_agent.cli:run" + + +def test_console_script_falls_back_in_source_only_environment(tmp_path): + python_executable = str(tmp_path / "bin" / "python") + assert _console_script_entrypoint(python_executable) == [ + python_executable, + "-c", + _CONSOLE_SCRIPT_FALLBACK, + ] + + +def test_run_command_forwards_run_status(monkeypatch): + def fake_run(*, args): + assert args.pr_url == "https://example.com/org/repo/pull/1" + assert args.command == "review" + return 1 + + monkeypatch.setattr(cli, "run", fake_run) + + assert cli.run_command("https://example.com/org/repo/pull/1", "/review") == 1 + + +@pytest.fixture +def process_entrypoints(): + return ( + _console_script_entrypoint(sys.executable), + [sys.executable, "-m", "pr_agent.cli"], + [sys.executable, "-m", "pr_agent.cli_pip"], + [sys.executable, "pr_agent/cli_pip.py"], + ) + + +@pytest.fixture +def argparse_entrypoints(process_entrypoints): + return process_entrypoints[:2] + + +@pytest.fixture +def controlled_process_env(tmp_path): + sitecustomize = tmp_path / "sitecustomize.py" + sitecustomize.write_text( + """ +import os + +from pr_agent.agent.pr_agent import PRAgent +from pr_agent.config_loader import get_settings + + +async def controlled_handle_request(self, pr_url, request, notify=None): + get_settings().set( + \"CONFIG.PROPAGATE_TOOL_ERRORS\", + os.environ.get(\"PR_AGENT_TEST_PROPAGATE\") == \"true\", + ) + result = os.environ.get(\"PR_AGENT_TEST_RESULT\") + if result == \"none\": + return None + if result == \"zero\": + return 0 + return result == \"true\" + + +PRAgent.handle_request = controlled_handle_request +""".lstrip(), + encoding="utf-8", + ) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + part for part in (str(tmp_path), env.get("PYTHONPATH")) if part + ) + return env + + +@pytest.mark.parametrize( + ("request_result", "propagate_tool_errors", "expected_status"), + [ + ("false", "true", 1), + ("false", "false", 0), + ], +) +def test_process_entrypoints_map_request_status( + process_entrypoints, + controlled_process_env, + request_result, + propagate_tool_errors, + expected_status, +): + env = controlled_process_env.copy() + env["PR_AGENT_TEST_RESULT"] = request_result + env["PR_AGENT_TEST_PROPAGATE"] = propagate_tool_errors + + for entrypoint in process_entrypoints: + completed = subprocess.run( + [*entrypoint, "--pr_url=https://example.com/org/repo/pull/1", "review"], + cwd=Path(__file__).parents[2], + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert completed.returncode == expected_status, ( + entrypoint, + completed.stdout, + completed.stderr, + ) + + +def test_process_entrypoints_preserve_argparse_status( + argparse_entrypoints, + controlled_process_env, +): + for entrypoint in argparse_entrypoints: + completed = subprocess.run( + [*entrypoint, "not-a-command"], + cwd=Path(__file__).parents[2], + env=controlled_process_env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert completed.returncode == 2, ( + entrypoint, + completed.stdout, + completed.stderr, + ) From b06126f763b6da8a9de99d00776e8a533faa98cb Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:47:57 +0545 Subject: [PATCH 098/238] fix: delete Bitbucket Server comments (#3376) --- .../bitbucket_server_provider.py | 21 ++++++++++++++++++- tests/unittest/test_bitbucket_provider.py | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index c3ae3f377a..2c379a7fc0 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -411,7 +411,26 @@ def remove_initial_comment(self): get_logger().exception(f"Failed to remove temp comments, error: {e}") def remove_comment(self, comment): - pass + comment_id = comment.get("id") if isinstance(comment, dict) else getattr(comment, "id", None) + comment_version = comment.get("version") if isinstance(comment, dict) else getattr(comment, "version", None) + if not isinstance(comment_id, int) or not isinstance(comment_version, int): + get_logger().warning( + f"Failed to remove Bitbucket Server comment: invalid id or version for {comment!r}" + ) + return False + + try: + self.bitbucket_client.delete_pull_request_comment( + self.workspace_slug, + self.repo_slug, + self.pr_num, + comment_id, + comment_version, + ) + return True + except Exception as e: + get_logger().exception(f"Failed to remove comment, error: {e}") + return False # function to create_inline_comment def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index d7b1350199..3cc5562abb 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -1136,6 +1136,24 @@ def test_get_repo_file_content_reads_from_target_ref(self): assert content == "repo context" provider.get_file.assert_called_once_with("AGENTS.md", "base-sha") + def test_remove_comment_deletes_comment_with_id_and_version(self): + provider = self._persistent_provider() + comment = SimpleNamespace(id=7, version=3) + + assert provider.remove_comment(comment) is True + + provider.bitbucket_client.delete_pull_request_comment.assert_called_once_with( + "AAA", "my-repo", 1, 7, 3 + ) + + def test_remove_comment_rejects_missing_version(self): + provider = self._persistent_provider() + comment = {"id": 7} + + assert provider.remove_comment(comment) is False + + provider.bitbucket_client.delete_pull_request_comment.assert_not_called() + def test_get_repo_file_content_from_default_branch(self): provider = BitbucketServerProvider.__new__(BitbucketServerProvider) provider.workspace_slug = "AAA" From d048e7284c2b88e54e6253dbbe6973c3ed90cbf7 Mon Sep 17 00:00:00 2001 From: Lukas Buck <103962359+L4XB@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:47:55 +0200 Subject: [PATCH 099/238] fix(reviewer): make the prompt examples agree with the types they declare (#3366) --- pr_agent/settings/pr_reviewer_prompts.toml | 8 +- .../test_pr_reviewer_prompt_example_types.py | 119 ++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 tests/unittest/test_pr_reviewer_prompt_example_types.py diff --git a/pr_agent/settings/pr_reviewer_prompts.toml b/pr_agent/settings/pr_reviewer_prompts.toml index dacb850ae3..c5fa8aa26e 100644 --- a/pr_agent/settings/pr_reviewer_prompts.toml +++ b/pr_agent/settings/pr_reviewer_prompts.toml @@ -112,7 +112,7 @@ class Review(BaseModel): contribution_time_cost_estimate: ContributionTimeCostEstimate = Field(description="An estimate of the time required to implement the changes, based on the quantity, quality, and complexity of the contribution, as well as the context from the PR description and commit messages.") {%- endif %} {%- if require_score %} - score: str = Field(description="Rate this PR on a scale of 0-100 (inclusive), where 0 means the worst possible PR code, and 100 means PR code of the highest quality, without any bugs or performance issues, that is ready to be merged immediately and run in production at scale.") + score: int = Field(description="Rate this PR on a scale of 0-100 (inclusive), where 0 means the worst possible PR code, and 100 means PR code of the highest quality, without any bugs or performance issues, that is ready to be merged immediately and run in production at scale.") {%- endif %} {%- if require_tests %} relevant_tests: str = Field(description="yes/no question: does this PR have relevant tests added or updated?") @@ -153,8 +153,7 @@ review: ... {%- endif %} {%- if require_estimate_effort_to_review %} - estimated_effort_to_review_[1-5]: | - 3 + estimated_effort_to_review_[1-5]: 3 {%- endif %} {%- if require_risk_assessment %} risk_level: | @@ -304,8 +303,7 @@ review: ... {%- endif %} {%- if require_estimate_effort_to_review %} - estimated_effort_to_review_[1-5]: | - 3 + estimated_effort_to_review_[1-5]: 3 {%- endif %} {%- if require_risk_assessment %} risk_level: | diff --git a/tests/unittest/test_pr_reviewer_prompt_example_types.py b/tests/unittest/test_pr_reviewer_prompt_example_types.py new file mode 100644 index 0000000000..b22967075b --- /dev/null +++ b/tests/unittest/test_pr_reviewer_prompt_example_types.py @@ -0,0 +1,119 @@ +"""The reviewer prompt's examples must agree with the types its schema declares. + +Named for the example TYPES, not `prompt_contract`: that name is taken by the +Jinja-variable contract tests beside this file, which answer a different +question about the same prompt. + +Issue #3354. The prompts describe the model's output as `class X(BaseModel)` +blocks that exist nowhere as Python, so nothing checks that the schema, the +example and the parser agree — and two fields had drifted in opposite +directions: + + estimated_effort_to_review_[1-5]: int schema + estimated_effort_to_review_[1-5]: | example — a block scalar, i.e. a str + 3 + score: str schema + score: 89 example — a bare int + +Same shape as #3319/#3325 in `pr_add_docs.toml`, and the same test shape: +parse the example the model is shown and hold it to the declared type. This +covers ONE file; #3354's full ask is a real pydantic model per top-level class +with a fixture per tool, which is a larger piece of work. + +Both example blocks are checked, not just the first: they drifted identically, +so a test that read one would go green while the other still taught the wrong +type. +""" + +import re + +import yaml + +from pr_agent.config_loader import get_settings + +# The declared type can be `int`, `List[Foo]` or `Union[List[Foo], str]`, and +# `Field(` may be called positionally, so match up to the `= Field(`. +_DECLARED = re.compile( + r"^\s*(?P[\w\[\]\-]+):\s*(?P[^=]+?)\s*=\s*Field\(", re.M +) + + +def _prompt() -> str: + """Both halves. The schema and the first example live in `system`; the + SECOND example lives in `user`, and they drifted identically -- a test that + read only one would go green while the other still taught the wrong type.""" + prompt = get_settings().pr_review_prompt + return f"{prompt.system}\n{prompt.user}" + + +def _declared_types() -> dict[str, str]: + """field name -> the type its `Field(...)` line declares.""" + return {m.group("field"): m.group("type") for m in _DECLARED.finditer(_prompt())} + + +def _example_blocks() -> list[str]: + """Every ```yaml example in the prompt, Jinja lines stripped. + + The blocks are Jinja-conditional, so the raw text is not valid YAML; the + `{%- ... %}` lines are control flow around the fields, never part of one. + """ + blocks = re.findall(r"```yaml\n(.*?)```", _prompt(), re.S) + return [ + "\n".join(line for line in b.splitlines() if not line.strip().startswith("{%")) + for b in blocks + ] + + +def _review_examples() -> list[dict]: + out = [] + for block in _example_blocks(): + parsed = yaml.safe_load(block) + if isinstance(parsed, dict) and "review" in parsed: + out.append(parsed["review"]) + return out + + +def test_the_prompt_still_carries_the_example_blocks_this_pins(): + """The control: if the prompt stops shipping examples, every assertion + below would pass by having nothing to check.""" + examples = _review_examples() + assert len(examples) == 2, f"expected both review examples, got {len(examples)}" + + +def test_the_effort_example_is_the_int_its_schema_declares(): + assert _declared_types()["estimated_effort_to_review_[1-5]"] == "int" + for example in _review_examples(): + value = example["estimated_effort_to_review_[1-5]"] + assert isinstance(value, int), ( + f"the schema declares int; the example teaches {type(value).__name__} " + f"({value!r}) — a block scalar is a str" + ) + + +def test_the_score_example_is_the_int_its_schema_declares(): + assert _declared_types()["score"] == "int" + for example in _review_examples(): + value = example["score"] + assert isinstance(value, int), ( + f"the schema declares int; the example teaches {type(value).__name__} " + f"({value!r})" + ) + + +def _taught_fields(value) -> set[str]: + """Every key an example teaches, at any depth.""" + if isinstance(value, dict): + return set(value) | {f for v in value.values() for f in _taught_fields(v)} + if isinstance(value, list): + return {f for v in value for f in _taught_fields(v)} + return set() + + +def test_every_field_the_examples_teach_is_one_the_schema_declares(): + """The drift that started #3354 was a field the examples kept after the + schema dropped it (`overall_compliance_level`, #3318). It sat under + `ticket_compliance_check`, so walk nested mappings and lists too.""" + declared = set(_declared_types()) + for example in _review_examples(): + unknown = sorted(_taught_fields(example) - declared) + assert not unknown, f"examples teach fields the schema does not declare: {unknown}" From 4e015033ad8ddb1395235d9bc2c7f1482568e15c Mon Sep 17 00:00:00 2001 From: Tal <169621860+taljeon@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:54:20 +0900 Subject: [PATCH 100/238] fix(review): retry malformed responses with fallback models (#3359) Co-authored-by: taljeon --- pr_agent/tools/pr_reviewer.py | 29 ++-- .../test_linked_ticket_token_budget.py | 2 + tests/unittest/test_pr_reviewer_core.py | 14 +- .../test_pr_reviewer_fallback_validation.py | 129 ++++++++++++++++++ .../test_review_large_diff_chunking.py | 17 ++- 5 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 tests/unittest/test_pr_reviewer_fallback_validation.py diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index dbc10c7ba3..9a395f4f23 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -753,6 +753,12 @@ def _should_publish_review_no_suggestions(self, pr_review: str) -> bool: return get_settings().pr_reviewer.get('publish_output_no_suggestions', True) or "No major issues detected" not in pr_review async def _prepare_prediction(self, model: str) -> None: + # Each model attempt owns a fresh result. A malformed primary must not + # leave state that can be mistaken for a successful fallback response. + self.prediction = None + self.prediction_data = None + self.review_chunk_count = 1 + self.review_failed_chunk_count = 0 raw_prompt_vars = getattr(self, "_raw_prompt_vars", getattr(self, "vars", None)) if raw_prompt_vars is not None: self.vars, self.token_handler = fit_related_tickets_to_prompt_budget( @@ -788,7 +794,9 @@ async def _prepare_prediction(self, model: str) -> None: if self.patches_diff: get_logger().debug("PR diff", diff=self.patches_diff) - self.prediction = await self._get_prediction(model) + prediction = await self._get_prediction(model) + self._load_valid_review_yaml(prediction) + self.prediction = prediction else: get_logger().warning(f"Empty diff for PR: {self.pr_url}") self.prediction = None @@ -830,19 +838,12 @@ async def _prepare_chunked_prediction(self, model: str, continue if isinstance(prediction, BaseException): raise prediction - data = self._load_review_yaml(prediction) - if not isinstance(data, dict) or not isinstance(data.get("review"), dict) or not data["review"]: - get_logger().warning(f"Failed to parse the review of chunk {chunk_index + 1}", - artifact={"data": data}) - continue + data = self._load_valid_review_yaml(prediction, source=f"review chunk {chunk_index + 1}") raw_predictions.append(prediction) chunk_outputs.append(data) if not chunk_outputs: - if chunk_errors: - raise chunk_errors[0] - get_logger().warning("No chunk produced a parsable review, falling back to a single review call") - return False + raise chunk_errors[0] # the raw text is kept for logging only; the merged verdict is in self.prediction_data self.prediction = "\n".join(raw_predictions) @@ -888,6 +889,14 @@ def _load_review_yaml(prediction: str) -> dict: "relevant_file:", "relevant_line:", "suggestion:"], first_key='review', last_key='security_concerns') + @classmethod + def _load_valid_review_yaml(cls, prediction: str, *, source: str = "model response") -> dict: + """Parse one prediction and require the minimum publishable review shape.""" + data = cls._load_review_yaml(prediction) + if not isinstance(data, dict) or not isinstance(data.get("review"), dict) or not data["review"]: + raise ValueError(f"{source} did not contain a non-empty review mapping") + return data + def _prepare_pr_review(self) -> str: """ Prepare the PR review by processing the AI prediction and generating a markdown-formatted text that summarizes diff --git a/tests/unittest/test_linked_ticket_token_budget.py b/tests/unittest/test_linked_ticket_token_budget.py index babf86da39..a54acb7647 100644 --- a/tests/unittest/test_linked_ticket_token_budget.py +++ b/tests/unittest/test_linked_ticket_token_budget.py @@ -204,6 +204,8 @@ def get_diff(_provider, token_handler, _model, **_kwargs): async def get_prediction(_model, *_args, **_kwargs): rendered_vars.append(tool.vars) + if tool_name == "review": + return "review:\n summary: prediction" return "prediction" monkeypatch.setattr(module, "fit_related_tickets_to_prompt_budget", fit_payload) diff --git a/tests/unittest/test_pr_reviewer_core.py b/tests/unittest/test_pr_reviewer_core.py index 985b0b64c7..9068d80e75 100644 --- a/tests/unittest/test_pr_reviewer_core.py +++ b/tests/unittest/test_pr_reviewer_core.py @@ -14,6 +14,8 @@ from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider from pr_agent.tools.pr_reviewer import PRReviewer, _review_failure_comment +_VALID_PREDICTION = "review:\n summary: prediction" + def _make_reviewer(git_provider=None): reviewer = PRReviewer.__new__(PRReviewer) @@ -87,7 +89,7 @@ def test_review_failure_comment_treats_quoted_false_as_disabled(): @pytest.mark.asyncio async def test_prepare_prediction_requests_remaining_files_and_preserves_tuple_result(): reviewer = _make_prediction_reviewer() - reviewer._get_prediction = AsyncMock(return_value="prediction") + reviewer._get_prediction = AsyncMock(return_value=_VALID_PREDICTION) with patch( "pr_agent.tools.pr_reviewer.get_pr_diff", @@ -105,34 +107,34 @@ async def test_prepare_prediction_requests_remaining_files_and_preserves_tuple_r ) assert reviewer.patches_diff == "diff" assert reviewer.remaining_files_list == ["src/one.py", "docs/two.md"] - assert reviewer.prediction == "prediction" + assert reviewer.prediction == _VALID_PREDICTION @pytest.mark.asyncio async def test_prepare_prediction_accepts_full_diff_string_when_token_budget_is_sufficient(): reviewer = _make_prediction_reviewer() - reviewer._get_prediction = AsyncMock(return_value="prediction") + reviewer._get_prediction = AsyncMock(return_value=_VALID_PREDICTION) with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"): await reviewer._prepare_prediction("model") assert reviewer.patches_diff == "diff" assert reviewer.remaining_files_list == [] - assert reviewer.prediction == "prediction" + assert reviewer.prediction == _VALID_PREDICTION @pytest.mark.asyncio async def test_prepare_prediction_keeps_incremental_review_compatible_with_tuple_result(): reviewer = _make_prediction_reviewer() reviewer.incremental = SimpleNamespace(is_incremental=True) - reviewer._get_prediction = AsyncMock(return_value="prediction") + reviewer._get_prediction = AsyncMock(return_value=_VALID_PREDICTION) with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["skipped.py"])): await reviewer._prepare_prediction("model") assert reviewer.patches_diff == "diff" assert reviewer.remaining_files_list == ["skipped.py"] - assert reviewer.prediction == "prediction" + assert reviewer.prediction == _VALID_PREDICTION def _render_review(reviewer, remaining_files, supports_gfm_markdown=False): diff --git a/tests/unittest/test_pr_reviewer_fallback_validation.py b/tests/unittest/test_pr_reviewer_fallback_validation.py new file mode 100644 index 0000000000..b4b4ccd877 --- /dev/null +++ b/tests/unittest/test_pr_reviewer_fallback_validation.py @@ -0,0 +1,129 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.config_loader import get_settings +from pr_agent.tools.pr_reviewer import PRReviewer +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +_TRACKED_KEYS = ( + "config.model", + "config.fallback_models", + "config.publish_output", + "config.is_auto_command", + "config.propagate_tool_errors", + "openai.deployment_id", + "openai.fallback_deployments", + "pr_reviewer.persistent_comment", +) + +_VALID_REVIEW = """review: + summary: fallback review +""" + + +@pytest.fixture +def fallback_models(): + snapshot = snapshot_settings(_TRACKED_KEYS) + settings = get_settings() + settings.set("config.model", "primary-model") + settings.set("config.fallback_models", ["fallback-model"]) + settings.set("openai.deployment_id", None) + settings.set("openai.fallback_deployments", []) + yield + restore_settings(snapshot) + + +def _make_reviewer(git_provider=None): + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.git_provider = git_provider or MagicMock() + reviewer.token_handler = MagicMock() + reviewer.pr_url = "https://example/pr/1" + reviewer.incremental = SimpleNamespace(is_incremental=False) + reviewer.remaining_files_list = [] + reviewer.prediction = None + reviewer.vars = {} + return reviewer + + +@pytest.mark.asyncio +async def test_malformed_primary_review_uses_fallback_model(fallback_models): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=["not valid review yaml", _VALID_REVIEW]) + + with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"): + await retry_with_fallback_models(reviewer._prepare_prediction, git_provider=reviewer.git_provider) + + assert reviewer._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert reviewer.prediction == _VALID_REVIEW + + +@pytest.mark.asyncio +async def test_malformed_primary_then_valid_fallback_publishes_review_without_failure( + monkeypatch, fallback_models +): + progress_comment = MagicMock() + git_provider = MagicMock() + git_provider.get_files.return_value = ["app.py"] + git_provider.publish_comment.return_value = progress_comment + git_provider.should_publish_review_as_thread.return_value = False + git_provider.supports_review_comment_identity.return_value = False + reviewer = _make_reviewer(git_provider) + reviewer._get_prediction = AsyncMock(side_effect=["not yaml", _VALID_REVIEW]) + reviewer._prepare_pr_review = MagicMock(return_value="rendered fallback review") + + settings = get_settings() + settings.set("config.publish_output", True) + settings.set("config.is_auto_command", False) + settings.set("config.propagate_tool_errors", False) + settings.set("pr_reviewer.persistent_comment", False) + + monkeypatch.setattr("pr_agent.tools.pr_reviewer.extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr( + "pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + lambda _pr, raw_vars, _system, _user, _model: (raw_vars, reviewer.token_handler), + ) + + with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"): + await reviewer.run() + + assert reviewer._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert git_provider.publish_comment.call_args_list == [ + call("Preparing review...", is_temporary=True), + call("rendered fallback review"), + ] + assert all("Failed to review PR" not in str(args) for args in git_provider.publish_comment.call_args_list) + git_provider.remove_comment.assert_called_once_with(progress_comment) + + +@pytest.mark.asyncio +async def test_all_malformed_models_publish_one_failure_result(monkeypatch, fallback_models): + progress_comment = MagicMock() + git_provider = MagicMock() + git_provider.get_files.return_value = ["app.py"] + git_provider.publish_comment.return_value = progress_comment + reviewer = _make_reviewer(git_provider) + reviewer._get_prediction = AsyncMock(side_effect=["not yaml", "review: {}"]) + + settings = get_settings() + settings.set("config.publish_output", True) + settings.set("config.is_auto_command", False) + settings.set("config.propagate_tool_errors", False) + + monkeypatch.setattr("pr_agent.tools.pr_reviewer.extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr( + "pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + lambda _pr, raw_vars, _system, _user, _model: (raw_vars, reviewer.token_handler), + ) + + with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"): + await reviewer.run() + + assert reviewer._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert git_provider.publish_comment.call_args_list == [ + call("Preparing review...", is_temporary=True), + call("Failed to review PR"), + ] + git_provider.remove_comment.assert_called_once_with(progress_comment) diff --git a/tests/unittest/test_review_large_diff_chunking.py b/tests/unittest/test_review_large_diff_chunking.py index a2b781dac0..06dfce44fd 100644 --- a/tests/unittest/test_review_large_diff_chunking.py +++ b/tests/unittest/test_review_large_diff_chunking.py @@ -235,7 +235,7 @@ async def test_a_failed_chunk_blocks_persistent_finding_resolution(chunking_enab @pytest.mark.asyncio -async def test_an_empty_chunk_does_not_lose_a_valid_sibling_or_trigger_fallback(chunking_enabled): +async def test_a_malformed_chunk_fails_the_model_attempt(chunking_enabled): reviewer = _make_reviewer() reviewer._get_prediction = AsyncMock(side_effect=["review: {}", CHUNK_B]) @@ -243,13 +243,12 @@ async def test_an_empty_chunk_does_not_lose_a_valid_sibling_or_trigger_fallback( patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", return_value=(["chunk-a", "chunk-b"], [])), + pytest.raises(ValueError, match="non-empty review"), ): await reviewer._prepare_prediction("model") assert reviewer._get_prediction.await_count == 2 - assert reviewer.prediction_data["review"]["score"] == "40" - assert reviewer.review_chunk_count == 2 - assert reviewer.review_failed_chunk_count == 1 + assert reviewer.prediction_data is None @pytest.mark.asyncio @@ -272,20 +271,20 @@ async def test_a_review_where_every_chunk_failed_raises_so_a_fallback_model_is_t ["not yaml at all", "nor is this"], ["review: {}", "review: {}"], ]) -async def test_chunks_without_nonempty_reviews_fall_back_to_a_single_call_review(chunking_enabled, - chunk_predictions): +async def test_chunks_without_nonempty_reviews_fail_the_model_attempt(chunking_enabled, + chunk_predictions): reviewer = _make_reviewer() - reviewer._get_prediction = AsyncMock(side_effect=[*chunk_predictions, CHUNK_A]) + reviewer._get_prediction = AsyncMock(side_effect=chunk_predictions) with ( patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", return_value=(["chunk-a", "chunk-b"], [])), + pytest.raises(ValueError, match="non-empty review"), ): await reviewer._prepare_prediction("model") - assert reviewer._get_prediction.await_count == 3 - assert reviewer.prediction == CHUNK_A + assert reviewer._get_prediction.await_count == 2 assert reviewer.prediction_data is None From d2f86506eb56c1d7ff041185b5bc42c8c5c8433d Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Mon, 14 Sep 2026 09:54:31 -0600 Subject: [PATCH 101/238] test(tools): pin the git-provider capability contract for callers (#3361) --- pr_agent/tools/pr_reviewer.py | 4 +- pr_agent/tools/pr_update_changelog.py | 5 + tests/unittest/test_pr_update_changelog.py | 14 ++ .../test_tool_capability_guard_contract.py | 164 ++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 tests/unittest/test_tool_capability_guard_contract.py diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 9a395f4f23..b9633d8e07 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -1188,7 +1188,9 @@ def _get_user_answers(self) -> Tuple[str, str]: question_str = "" answer_str = "" - if self.is_answer: + if self.is_answer and self.git_provider.is_supported("get_issue_comments"): + # __init__ already raises when is_answer is True and this is unsupported, so + # this repeats that check locally rather than relying on the caller for it. discussion_messages = self.git_provider.get_issue_comments() # providers return the comments oldest-first. PyGithub's PaginatedList reverses lazily, diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index a86d713010..d9126e3d1a 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -167,6 +167,11 @@ def _prepare_changelog_update(self) -> Tuple[str, str]: return new_file_content, answer def _push_changelog_update(self, new_file_content, answer): + if not self.git_provider.is_supported("push_code"): + # Its only caller already gates on self.commit_changelog, which is False + # whenever this capability is missing; kept local so the guard holds even + # if a future caller reaches this method some other way. + return if get_settings().pr_update_changelog.get("skip_ci_on_push", True): commit_message = "[skip ci] Update CHANGELOG.md" else: diff --git a/tests/unittest/test_pr_update_changelog.py b/tests/unittest/test_pr_update_changelog.py index c36809d5a0..d7cf0ca878 100644 --- a/tests/unittest/test_pr_update_changelog.py +++ b/tests/unittest/test_pr_update_changelog.py @@ -354,6 +354,20 @@ def test_push_changelog_update(self, changelog_tool, mock_git_provider): message="[skip ci] Update CHANGELOG.md" ) + def test_push_changelog_update_never_calls_create_or_update_pr_file_when_push_code_is_unsupported( + self, changelog_tool, mock_git_provider): + """A provider that declines `push_code` (e.g. restricted_mode) must never reach + `create_or_update_pr_file`, even if a future caller invokes this method directly + without going through `run()`'s own `self.commit_changelog` gate.""" + mock_git_provider.create_or_update_pr_file = MagicMock() + mock_git_provider.is_supported.return_value = False + new_content = "# Updated changelog content" + answer = "Changes made" + + changelog_tool._push_changelog_update(new_content, answer) + + mock_git_provider.create_or_update_pr_file.assert_not_called() + def test_gitlab_provider_method_detection(self, changelog_tool, mock_git_provider): """Test that the tool correctly detects GitLab provider method availability.""" # Arrange diff --git a/tests/unittest/test_tool_capability_guard_contract.py b/tests/unittest/test_tool_capability_guard_contract.py new file mode 100644 index 0000000000..85cbe244a2 --- /dev/null +++ b/tests/unittest/test_tool_capability_guard_contract.py @@ -0,0 +1,164 @@ +"""Contract: no tool may reach a git-provider method its provider has declared unsupported. + +`test_git_provider_method_contract.py` pins what providers DO for a given capability; +this file pins what CALLERS in `pr_agent/tools/` do with the answer. A tool that calls a +capability-gated `self.git_provider.(...)` without checking `is_supported()` first +raises (or silently corrupts output) on any provider that declines that capability: #2932 +was exactly this, for `get_labels` in `pr_reviewer.py`. + +Scope is intentionally the ENCLOSING FUNCTION, not the enclosing class: a guard several +frames away (a flag cached in `__init__`, a raise before a later call) is real, but it is +also invisible to the next person editing the callee, which is the failure mode this test +exists to catch. Every current call site either already checks locally or was given a +local check by this change (see `pr_update_changelog.py::_push_changelog_update` and +`pr_reviewer.py::_get_user_answers`) precisely so this test can be scoped this way without +false positives. +""" +import ast +from pathlib import Path + +TOOLS_DIR = Path(__file__).resolve().parent.parent.parent / "pr_agent" / "tools" +PROVIDERS_DIR = Path(__file__).resolve().parent.parent.parent / "pr_agent" / "git_providers" + +# Base GitProvider methods that at least one provider declines (see +# `_provider_declinable_capabilities` below), mapped to the `is_supported()` capability +# string that guards each. This mapping is domain knowledge (e.g. "get_labels covers +# publish_labels and get_pr_labels") and cannot be derived from the providers alone, so +# unlike the capability set below it is maintained by hand. +GUARDED_METHODS = { + "get_pr_labels": "get_labels", + "publish_labels": "get_labels", + "publish_inline_comments": "publish_inline_comments", + "create_inline_comment": "create_inline_comment", + "get_issue_comments": "get_issue_comments", + "create_or_update_pr_file": "push_code", +} + + +def _tool_files(): + return sorted(TOOLS_DIR.glob("*.py")) + + +def _is_supported_capability_strings(node): + """Every literal string passed as the first arg to an `is_supported(...)` call + anywhere under `node` (a module, class or function node).""" + caps = set() + for inner in ast.walk(node): + if (isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "is_supported" + and inner.args + and isinstance(inner.args[0], ast.Constant) + and isinstance(inner.args[0].value, str)): + caps.add(inner.args[0].value) + return caps + + +def _is_git_provider(node): + """`self.git_provider`, or a bare `git_provider` name in a helper that takes the + provider as a parameter (the pattern `pr_code_suggestions.py` already uses).""" + if isinstance(node, ast.Name): + return node.id == "git_provider" + return (isinstance(node, ast.Attribute) + and node.attr == "git_provider" + and isinstance(node.value, ast.Name) + and node.value.id == "self") + + +def _git_provider_call_sites(func_node): + """Every `self.git_provider.(...)` or `git_provider.(...)` call + directly under a function node, keyed by method name.""" + calls = {} + for inner in ast.walk(func_node): + if (isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and _is_git_provider(inner.func.value)): + calls.setdefault(inner.func.attr, []).append(inner) + return calls + + +def _function_nodes(tree): + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield node + + +def _unguarded_call_sites(): + """(file, function, method, lineno) for every capability-gated + `self.git_provider.(...)` call with no matching `is_supported()` check + anywhere in its own enclosing function. + + This is membership, not control flow: it does not check that the guard runs + before the call, sits in the branch that actually reaches it, or that its result + is used. A guard placed after the call, in an unrelated branch, or with its + result ignored still satisfies this check. It catches a capability check being + absent from the function entirely, which is the regression this test guards + against; it will not catch one being present but misplaced. + """ + hits = [] + for path in _tool_files(): + tree = ast.parse(path.read_text(), filename=str(path)) + for func_node in _function_nodes(tree): + guarded_caps = _is_supported_capability_strings(func_node) + calls = _git_provider_call_sites(func_node) + for method, cap in GUARDED_METHODS.items(): + for call in calls.get(method, []): + if cap not in guarded_caps: + hits.append((path.name, func_node.name, method, call.lineno)) + return hits + + +def _provider_declinable_capabilities(): + """Every capability string at least one provider's `is_supported()` special-cases. + + Parsed from `pr_agent/git_providers/*.py` rather than hardcoded, so a new provider + declining a new capability grows this set (and the typo check below) automatically + instead of it silently going stale. + + Collects any string literal compared inside `is_supported()`, without checking + which branch it feeds or whether that branch returns False. A capability named in + a comparison that always returns True, or in an unrelated comparison, is treated + as declinable the same as one a provider genuinely rejects. + """ + caps = set() + for path in sorted(PROVIDERS_DIR.glob("*.py")): + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "is_supported": + for inner in ast.walk(node): + if isinstance(inner, ast.Compare): + for comparator in inner.comparators: + if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str): + caps.add(comparator.value) + elif isinstance(comparator, (ast.List, ast.Tuple, ast.Set)): + for elt in comparator.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + caps.add(elt.value) + return caps + + +def _is_supported_strings_used_in_tools(): + caps = set() + for path in _tool_files(): + tree = ast.parse(path.read_text(), filename=str(path)) + caps |= _is_supported_capability_strings(tree) + return caps + + +def test_every_capability_gated_provider_call_is_locally_guarded(): + hits = _unguarded_call_sites() + assert hits == [], ( + "self.git_provider.() reachable without checking is_supported() in the " + "same function first: " + + ", ".join(f"{f}:{fn}() calls {m}() at line {ln}" for f, fn, m, ln in hits) + ) + + +def test_every_is_supported_string_in_tools_is_a_capability_some_provider_declines(): + used = _is_supported_strings_used_in_tools() + declinable = _provider_declinable_capabilities() + typos = used - declinable + assert not typos, ( + f"is_supported() called in pr_agent/tools/ with a string no provider ever " + f"declines, so the check always passes: {typos}" + ) From a4dfdc5a6efb66130832a00cb78c9af93f46a064 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:44:37 +0545 Subject: [PATCH 102/238] feat: add typed prompt output models (#3371) Co-authored-by: Utsab --- pr_agent/algo/output_models.py | 205 +++++++++++ .../settings/pr_evaluate_prompt_response.toml | 8 +- pr_agent/settings/pr_reviewer_prompts.toml | 12 +- tests/unittest/test_output_models.py | 341 ++++++++++++++++++ 4 files changed, 555 insertions(+), 11 deletions(-) create mode 100644 pr_agent/algo/output_models.py create mode 100644 tests/unittest/test_output_models.py diff --git a/pr_agent/algo/output_models.py b/pr_agent/algo/output_models.py new file mode 100644 index 0000000000..9f7c47e79a --- /dev/null +++ b/pr_agent/algo/output_models.py @@ -0,0 +1,205 @@ +"""Pydantic models for structured outputs described by prompt templates.""" + +from enum import Enum +from typing import List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator + + +class SubPR(BaseModel): + model_config = ConfigDict(extra="forbid") + + relevant_files: List[str] + title: str + + +class KeyIssuesComponentLink(BaseModel): + model_config = ConfigDict(extra="forbid") + + relevant_file: str + issue_header: str + issue_content: str + start_line: int + end_line: int + + +class TodoSection(BaseModel): + model_config = ConfigDict(extra="forbid") + + relevant_file: str + line_number: int + content: str + + +class TicketCompliance(BaseModel): + model_config = ConfigDict(extra="forbid") + + ticket_url: str + ticket_requirements: str + fully_compliant_requirements: str + not_compliant_requirements: str + requires_further_human_verification: str + + +class ContributionTimeCostEstimate(BaseModel): + model_config = ConfigDict(extra="forbid") + + best_case: str + average_case: str + worst_case: str + + +class Review(BaseModel): + model_config = ConfigDict(extra="forbid") + + ticket_compliance_check: Optional[List[TicketCompliance]] = None + estimated_effort_to_review: Optional[StrictInt] = Field( + default=None, alias="estimated_effort_to_review_[1-5]", ge=1, le=5 + ) + risk_level: Optional[Literal["low", "medium", "high"]] = None + merge_recommendation: Optional[Literal["safe_to_merge", "merge_with_caution", "changes_required"]] = None + review_priority_files: Optional[List[str]] = None + contribution_time_cost_estimate: Optional[ContributionTimeCostEstimate] = None + score: Optional[StrictInt] = Field(default=None, ge=0, le=100) + relevant_tests: Optional[Literal["Yes", "No"]] = None + insights_from_user_answers: Optional[str] = None + key_issues_to_review: List[KeyIssuesComponentLink] + security_concerns: Optional[str] = None + todo_sections: Optional[Union[List[TodoSection], str]] = None + can_be_split: Optional[List[SubPR]] = Field(default=None, max_length=3) + + @field_validator("risk_level", "merge_recommendation", "relevant_tests", mode="before") + @classmethod + def _strip_literal_values(cls, value): + return value.strip() if isinstance(value, str) else value + + +class PRReview(BaseModel): + model_config = ConfigDict(extra="forbid") + + review: Review + + +class CodeSuggestion(BaseModel): + relevant_file: str + language: str + existing_code: str + suggestion_content: str + improved_code: str + one_sentence_summary: str + label: str + + +class PRCodeSuggestions(BaseModel): + code_suggestions: List[CodeSuggestion] + + +class CodeSuggestionFeedback(BaseModel): + suggestion_summary: str + relevant_file: str + relevant_lines_start: int + relevant_lines_end: int + suggestion_score: int = Field(ge=0, le=10) + why: str + + +class PRCodeSuggestionsFeedback(BaseModel): + code_suggestions: List[CodeSuggestionFeedback] + + +class PRType(str, Enum): + BUG_FIX = "Bug fix" + TESTS = "Tests" + ENHANCEMENT = "Enhancement" + DOCUMENTATION = "Documentation" + OTHER = "Other" + + +class FileDescription(BaseModel): + filename: str + changes_summary: Optional[str] = None + changes_title: str + label: str + + +class PRDescription(BaseModel): + type: List[PRType] = Field(min_length=1) + description: Optional[str] = None + title: str + changes_diagram: Optional[str] = None + pr_files: Optional[List[FileDescription]] = Field(default=None, max_length=20) + + +class PRDescriptionHeaders(BaseModel): + type: List[PRType] = Field(min_length=1) + description: Optional[str] = None + title: str + changes_diagram: Optional[str] = None + + +class PRFilesWalkthrough(BaseModel): + pr_files: List[FileDescription] + + +class Label(str, Enum): + BUG_FIX = "Bug fix" + TESTS = "Tests" + ENHANCEMENT = "Enhancement" + DOCUMENTATION = "Documentation" + OTHER = "Other" + + +class Labels(BaseModel): + labels: List[str] + + +class CodeDocumentationItem(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + relevant_file: str = Field(alias="relevant file") + relevant_line: StrictInt = Field(alias="relevant line", ge=1) + doc_placement: Literal["before", "after"] = Field(alias="doc placement") + documentation: str + + +class CodeDocumentation(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + code_documentation: List[CodeDocumentationItem] = Field(alias="Code Documentation") + + @field_validator("code_documentation", mode="after") + @classmethod + def _reject_duplicate_items(cls, items): + serialized = [tuple(sorted(item.model_dump().items())) for item in items] + if len(serialized) != len(set(serialized)): + raise ValueError("Code Documentation items must be unique") + return items + + +class PRRankResponses(BaseModel): + which_response_was_better: Literal[0, 1, 2] + why: str + score_response1: int = Field(ge=1, le=10) + score_response2: int = Field(ge=1, le=10) + + +class RelevantSection(BaseModel): + file_name: str + relevant_section_header_string: str + + +class DocHelper(BaseModel): + user_question: str + response: str + relevant_sections: List[RelevantSection] + question_is_relevant: Optional[int] = Field(default=None, ge=0, le=1) + + +class FileIdxAndPath(BaseModel): + idx: int = Field(ge=0) + file_name: str + + +class DocHeadingsHelper(BaseModel): + user_question: str + relevant_files_ranking: List[FileIdxAndPath] diff --git a/pr_agent/settings/pr_evaluate_prompt_response.toml b/pr_agent/settings/pr_evaluate_prompt_response.toml index 9a68139307..b6f821befa 100644 --- a/pr_agent/settings/pr_evaluate_prompt_response.toml +++ b/pr_agent/settings/pr_evaluate_prompt_response.toml @@ -44,9 +44,9 @@ After that, rank each response. Criterions to rank each response: - Don't necessarily rank higher a response that is longer. A shorter response might be better if it is more concise, and still addresses the task better. -The output must be a YAML object equivalent to type $PRRankRespones, according to the following Pydantic definitions: +The output must be a YAML object equivalent to type $PRRankResponses, according to the following Pydantic definitions: ===== -class PRRankRespones(BaseModel): +class PRRankResponses(BaseModel): which_response_was_better: Literal[0, 1, 2] = Field(description="A number indicating which response was better. 0 means both responses are equally good.") why: str = Field(description="In a short and concise manner, explain why the chosen response is better than the other. Be specific and give examples if relevant.") score_response1: int = Field(description="A score between 1 and 10, indicating the quality of the response1, based on the criterions mentioned in the prompt.") @@ -56,8 +56,8 @@ class PRRankRespones(BaseModel): Example output: ```yaml -which_response_was_better: "X" -why: "Response X is better because it is more practical, and addresses the task requirements better since ..." +which_response_was_better: 1 +why: "Response 1 is better because it is more practical, and addresses the task requirements better since ..." score_response1: ... score_response2: ... ``` diff --git a/pr_agent/settings/pr_reviewer_prompts.toml b/pr_agent/settings/pr_reviewer_prompts.toml index c5fa8aa26e..83f17866e1 100644 --- a/pr_agent/settings/pr_reviewer_prompts.toml +++ b/pr_agent/settings/pr_reviewer_prompts.toml @@ -100,10 +100,10 @@ class Review(BaseModel): estimated_effort_to_review_[1-5]: int = Field(description="Estimate, on a scale of 1-5 (inclusive), the time and effort required to review this PR by an experienced and knowledgeable developer. 1 means short and easy review, 5 means long and hard review. Take into account the size, complexity, quality, and the needed changes of the PR code diff.") {%- endif %} {%- if require_risk_assessment %} - risk_level: str = Field(description="Overall risk level of this PR. Answer with exactly one of: low, medium, high. Use high only when the PR introduces a clear bug, security concern, or major logic risk. Use medium when the PR is not clearly broken but contains non-trivial areas that require careful human verification. Use low when the PR is small, low-impact, and no important issues are identified.") + risk_level: Literal["low", "medium", "high"] = Field(description="Overall risk level of this PR. Answer with exactly one of: low, medium, high. Use high only when the PR introduces a clear bug, security concern, or major logic risk. Use medium when the PR is not clearly broken but contains non-trivial areas that require careful human verification. Use low when the PR is small, low-impact, and no important issues are identified.") {%- endif %} {%- if require_merge_recommendation %} - merge_recommendation: str = Field(description="Overall merge recommendation for this PR. Answer with exactly one of: safe_to_merge, merge_with_caution, changes_required. Use changes_required when there are clear issues that should be fixed before merge. Use merge_with_caution when the PR seems acceptable but still deserves focused reviewer attention. Use safe_to_merge when no important blockers or risks are identified.") + merge_recommendation: Literal["safe_to_merge", "merge_with_caution", "changes_required"] = Field(description="Overall merge recommendation for this PR. Answer with exactly one of: safe_to_merge, merge_with_caution, changes_required. Use changes_required when there are clear issues that should be fixed before merge. Use merge_with_caution when the PR seems acceptable but still deserves focused reviewer attention. Use safe_to_merge when no important blockers or risks are identified.") {%- endif %} {%- if require_priority_files %} review_priority_files: List[str] = Field(description="A short list of the most important files a human reviewer should inspect first. Return an empty list if the PR is too small or no file deserves special attention.") @@ -115,7 +115,7 @@ class Review(BaseModel): score: int = Field(description="Rate this PR on a scale of 0-100 (inclusive), where 0 means the worst possible PR code, and 100 means PR code of the highest quality, without any bugs or performance issues, that is ready to be merged immediately and run in production at scale.") {%- endif %} {%- if require_tests %} - relevant_tests: str = Field(description="yes/no question: does this PR have relevant tests added or updated?") + relevant_tests: Literal["Yes", "No"] = Field(description="Does this PR have relevant tests added or updated? Answer exactly Yes or No.") {%- endif %} {%- if question_str %} insights_from_user_answers: str = Field(description="shortly summarize the insights you gained from the user's answers to the questions") @@ -156,12 +156,10 @@ review: estimated_effort_to_review_[1-5]: 3 {%- endif %} {%- if require_risk_assessment %} - risk_level: | - low + risk_level: low {%- endif %} {%- if require_merge_recommendation %} - merge_recommendation: | - safe_to_merge + merge_recommendation: safe_to_merge {%- endif %} {%- if require_priority_files %} review_priority_files: diff --git a/tests/unittest/test_output_models.py b/tests/unittest/test_output_models.py new file mode 100644 index 0000000000..3381698e2b --- /dev/null +++ b/tests/unittest/test_output_models.py @@ -0,0 +1,341 @@ +import re +from pathlib import Path +from types import UnionType +from typing import get_args, get_origin + +import pytest +from pydantic import StrictInt + +from pr_agent.algo.output_models import ( + CodeDocumentation, + CodeSuggestion, + CodeSuggestionFeedback, + ContributionTimeCostEstimate, + DocHeadingsHelper, + DocHelper, + FileDescription, + FileIdxAndPath, + KeyIssuesComponentLink, + Label, + Labels, + PRCodeSuggestions, + PRCodeSuggestionsFeedback, + PRDescription, + PRDescriptionHeaders, + PRFilesWalkthrough, + PRRankResponses, + PRReview, + PRType, + RelevantSection, + Review, + SubPR, + TicketCompliance, + TodoSection, +) +from pr_agent.algo.utils import load_yaml + + +def _review_fixture(): + issue = { + "relevant_file": "src/app.py", + "issue_header": "Possible Bug", + "issue_content": "The error path can lose the original exception.", + "start_line": 10, + "end_line": 12, + } + return { + "review": { + "estimated_effort_to_review_[1-5]": 3, + "risk_level": "medium", + "merge_recommendation": "merge_with_caution", + "review_priority_files": ["src/app.py"], + "contribution_time_cost_estimate": {"best_case": "45m", "average_case": "2h", "worst_case": "5h"}, + "score": 89, + "relevant_tests": "Yes", + "insights_from_user_answers": "The deployment target is Linux.", + "key_issues_to_review": [issue], + "security_concerns": "No", + "todo_sections": [{"relevant_file": "src/app.py", "line_number": 20, "content": "Remove fallback."}], + "can_be_split": [{"relevant_files": ["src/app.py"], "title": "Improve error handling"}], + "ticket_compliance_check": [{ + "ticket_url": "#123", + "ticket_requirements": "Handle errors.", + "fully_compliant_requirements": "Handle errors.", + "not_compliant_requirements": "", + "requires_further_human_verification": "", + }], + } + } + + +@pytest.mark.parametrize( + ("model", "payload"), + [ + (SubPR, {"relevant_files": ["src/app.py"], "title": "Improve error handling"}), + (KeyIssuesComponentLink, _review_fixture()["review"]["key_issues_to_review"][0]), + (TodoSection, {"relevant_file": "src/app.py", "line_number": 20, "content": "Remove fallback."}), + (TicketCompliance, _review_fixture()["review"]["ticket_compliance_check"][0]), + (ContributionTimeCostEstimate, {"best_case": "45m", "average_case": "2h", "worst_case": "5h"}), + (Review, _review_fixture()["review"]), + (PRReview, _review_fixture()), + (CodeSuggestion, { + "relevant_file": "src/app.py", "language": "python", "existing_code": "return value", + "suggestion_content": "Handle the missing value.", "improved_code": "return value or default", + "one_sentence_summary": "Handle missing values", "label": "possible bug", + }), + (PRCodeSuggestions, {"code_suggestions": [{ + "relevant_file": "src/app.py", "language": "python", "existing_code": "return value", + "suggestion_content": "Handle the missing value.", "improved_code": "return value or default", + "one_sentence_summary": "Handle missing values", "label": "possible bug", + }]}), + (PRCodeSuggestionsFeedback, {"code_suggestions": [{ + "suggestion_summary": "Handle missing values", "relevant_file": "src/app.py", + "relevant_lines_start": 10, "relevant_lines_end": 10, "suggestion_score": 8, + "why": "The change prevents a runtime failure.", + }]}), + (PRDescription, {"type": ["Bug fix"], "description": "Fix a runtime failure.", "title": "Fix runtime failure", + "changes_diagram": "flowchart LR\nA --> B", "pr_files": [{ + "filename": "src/app.py", "changes_summary": "- Handle failures", + "changes_title": "Handle runtime failures", "label": "bug fix", + }]}), + (PRDescriptionHeaders, {"type": ["Tests"], "description": "Cover the new behavior.", + "title": "Describe the test changes", "changes_diagram": ""}), + (FileDescription, {"filename": "src/app.py", "changes_summary": "- Handle failures", + "changes_title": "Handle runtime failures", "label": "bug fix"}), + (PRFilesWalkthrough, {"pr_files": [{ + "filename": "src/app.py", "changes_summary": "- Handle failures", + "changes_title": "Handle runtime failures", "label": "bug fix", + }]}), + (Labels, {"labels": ["Bug fix", "Tests"]}), + (CodeDocumentation, {"Code Documentation": [{ + "relevant file": "src/app.py", "relevant line": 12, "doc placement": "after", + "documentation": "Document the handler.", + }]}), + (PRRankResponses, {"which_response_was_better": 1, "why": "It is clearer.", "score_response1": 9, "score_response2": 7}), + (DocHelper, {"user_question": "How?", "response": "Use the helper.", "relevant_sections": [{ + "file_name": "docs/guide.md", "relevant_section_header_string": "## Usage", + }], "question_is_relevant": 1}), + (DocHeadingsHelper, {"user_question": "How?", "relevant_files_ranking": [{"idx": 0, "file_name": "docs/guide.md"}]}), + ], +) +def test_output_models_validate_complete_fixtures(model, payload): + model.model_validate(payload) + + +def test_review_alias_accepts_prompt_field_name(): + assert Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review_[1-5]": 3}).estimated_effort_to_review == 3 + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review": 3}) + + +def test_review_rejects_coercible_numeric_types_and_strips_prompt_literals(): + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "score": "89"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "score": True}) + + review = Review.model_validate({ + "key_issues_to_review": [], + "risk_level": "low\n", + "merge_recommendation": "safe_to_merge\n", + "relevant_tests": "No\n", + }) + assert review.risk_level == "low" + assert review.merge_recommendation == "safe_to_merge" + assert review.relevant_tests == "No" + + +def test_required_label_and_list_constraints_are_enforced(): + suggestion = { + "relevant_file": "src/app.py", "language": "python", "existing_code": "return value", + "suggestion_content": "Handle the missing value.", "improved_code": "return value or default", + "one_sentence_summary": "Handle missing values", + } + with pytest.raises(ValueError): + CodeSuggestion.model_validate(suggestion) + with pytest.raises(ValueError): + PRRankResponses.model_validate({"which_response_was_better": 3, "why": "No", "score_response1": 1, "score_response2": 1}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "can_be_split": [{"relevant_files": [], "title": "x"}] * 4}) + with pytest.raises(ValueError): + PRDescription.model_validate({"type": ["Tests"], "title": "x", "pr_files": [{ + "filename": "x", "changes_title": "x", "label": "x", + }] * 21}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review_[1-5]": 0}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "score": 101}) + with pytest.raises(ValueError): + PRCodeSuggestionsFeedback.model_validate({"code_suggestions": [{ + "suggestion_summary": "x", "relevant_file": "x", "relevant_lines_start": 1, + "relevant_lines_end": 1, "suggestion_score": 11, "why": "x", + }]}) + with pytest.raises(ValueError): + PRRankResponses.model_validate({"which_response_was_better": 1, "why": "x", "score_response1": 0, "score_response2": 11}) + with pytest.raises(ValueError): + DocHelper.model_validate({"user_question": "x", "response": "x", "relevant_sections": [], "question_is_relevant": 2}) + with pytest.raises(ValueError): + DocHeadingsHelper.model_validate({"user_question": "x", "relevant_files_ranking": [{"idx": -1, "file_name": "x"}]}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "risk_level": "critical"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "merge_recommendation": "approve"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "relevant_tests": "Maybe"}) + with pytest.raises(ValueError): + PRDescription.model_validate({"type": [], "title": "x"}) + with pytest.raises(ValueError): + PRDescriptionHeaders.model_validate({"type": [], "title": "x"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [{ + "relevant_file": "x", "issue_header": "x", "issue_content": "x", + "start_line": 1, "end_line": 1, "unexpected": "x", + }]}) + + +def _split_type_args(value): + parts, depth, start = [], 0, 0 + for index, character in enumerate(value): + if character == "[": + depth += 1 + elif character == "]": + depth -= 1 + elif character == "," and depth == 0: + parts.append(value[start:index]) + start = index + 1 + parts.append(value[start:]) + return parts + + +def _prompt_type_signature(annotation): + annotation = annotation.strip().replace(" ", "") + if annotation.startswith("Optional["): + annotation = annotation[9:-1] + if annotation.startswith("Union["): + return ("union", tuple(sorted( + (_prompt_type_signature(item) for item in _split_type_args(annotation[6:-1])), key=repr + ))) + if annotation.startswith("List["): + return ("list", _prompt_type_signature(annotation[5:-1])) + if annotation.startswith("Literal["): + values = tuple(value.strip().strip('"').strip("'") for value in annotation[8:-1].split(",")) + return ("literal", values) + if annotation == "Label": + return "str" + if annotation == "relevant_section": + return "RelevantSection" + if annotation == "file_idx_and_path": + return "FileIdxAndPath" + return annotation + + +def _model_type_signature(annotation): + origin = get_origin(annotation) + if str(origin) == "typing.Annotated": + return _model_type_signature(get_args(annotation)[0]) + if annotation is StrictInt: + return "int" + if str(origin) == "typing.Literal": + return ("literal", tuple(str(value) for value in get_args(annotation))) + if origin in (list,): + return ("list", _model_type_signature(get_args(annotation)[0])) + if origin in (UnionType,): + args = get_args(annotation) + elif str(origin) == "typing.Union": + args = get_args(annotation) + else: + return getattr(annotation, "__name__", str(annotation).split(".")[-1]) + members = tuple(sorted( + (_model_type_signature(item) for item in args if item is not type(None)), key=repr + )) + return members[0] if len(members) == 1 else ("union", members) + + +PROMPT_MODELS = { + "pr_reviewer_prompts.toml": {"SubPR": SubPR, "KeyIssuesComponentLink": KeyIssuesComponentLink, + "TodoSection": TodoSection, "TicketCompliance": TicketCompliance, + "ContributionTimeCostEstimate": ContributionTimeCostEstimate, "Review": Review, + "PRReview": PRReview}, + "pr_description_prompts.toml": {"FileDescription": FileDescription, "PRDescription": PRDescription}, + "pr_description_only_description_prompts.toml": {"PRDescriptionHeaders": PRDescriptionHeaders}, + "pr_description_only_files_prompts.toml": {"FileDescription": FileDescription, "PRFilesWalkthrough": PRFilesWalkthrough}, + "pr_custom_labels.toml": {"Labels": Labels}, + "pr_evaluate_prompt_response.toml": {"PRRankResponses": PRRankResponses}, + "pr_help_prompts.toml": {"relevant_section": RelevantSection, "DocHelper": DocHelper}, + "pr_help_docs_prompts.toml": {"relevant_section": RelevantSection, "DocHelper": DocHelper}, + "pr_help_docs_headings_prompts.toml": {"file_idx_and_path": FileIdxAndPath, "DocHeadingsHelper": DocHeadingsHelper}, + "code_suggestions/pr_code_suggestions_prompts.toml": {"CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions}, + "code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml": {"CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions}, + "code_suggestions/pr_code_suggestions_reflect_prompts.toml": {"CodeSuggestionFeedback": CodeSuggestionFeedback, "PRCodeSuggestionsFeedback": PRCodeSuggestionsFeedback}, +} + + +def test_prompt_fields_are_present_in_output_models(): + root = Path(__file__).parents[2] / "pr_agent" / "settings" + for relative_path, classes in PROMPT_MODELS.items(): + text = (root / relative_path).read_text(encoding="utf-8") + for class_name, model in classes.items(): + class_start = text.index(f"class {class_name}(BaseModel):") + block_start = class_start + len(f"class {class_name}(BaseModel):") + next_class = text.find("\nclass ", block_start) + separator = text.find("=====", block_start) + block_end = min(value for value in (next_class, separator) if value >= 0) + block = text[block_start:block_end] + declared = set(re.findall(r"^ ([A-Za-z_][A-Za-z0-9_\[\]-]*):", block, re.MULTILINE)) + model_fields = set(model.model_fields) + aliases = {field.alias for field in model.model_fields.values() if field.alias} + assert declared <= model_fields | aliases, f"{relative_path}: {class_name} has unmodelled fields" + for field_name, annotation in re.findall( + r"^ ([A-Za-z_][A-Za-z0-9_\[\]-]*):\s*([^=]+)", block, re.MULTILINE + ): + field = next((value for value in model.model_fields.values() + if value.alias == field_name or value.alias is None and value.validation_alias == field_name), None) + if field is None: + field = model.model_fields.get(field_name) + assert field is not None, f"{relative_path}: {class_name} field {field_name} is missing" + assert _prompt_type_signature(annotation) == _model_type_signature(field.annotation), ( + f"{relative_path}: {class_name}.{field_name} type drift" + ) + + +def test_prompt_enum_contracts_are_preserved(): + assert {member.value for member in PRType} == {"Bug fix", "Tests", "Enhancement", "Documentation", "Other"} + assert {member.value for member in Label} == {"Bug fix", "Tests", "Enhancement", "Documentation", "Other"} + + +def test_add_docs_prompt_matches_output_model(): + prompt = (Path(__file__).parents[2] / "pr_agent" / "settings" / "pr_add_docs.toml").read_text(encoding="utf-8") + assert "Code Documentation:" in prompt + assert "relevant file:" in prompt + assert "relevant line:" in prompt + assert "doc placement:" in prompt + assert "documentation:" in prompt + CodeDocumentation.model_validate({"Code Documentation": [{ + "relevant file": "src/app.py", "relevant line": 12, "doc placement": "after", + "documentation": "Document the handler.", + }]}) + + +def test_duplicate_doc_items_are_rejected(): + item = { + "relevant file": "src/app.py", "relevant line": 12, "doc placement": "after", + "documentation": "Document the handler.", + } + with pytest.raises(ValueError): + CodeDocumentation.model_validate({"Code Documentation": [item, item]}) + + +def test_estimate_effort_example_is_a_strict_integer(): + text = "estimated_effort_to_review_[1-5]: 3" + assert type(load_yaml(text)["estimated_effort_to_review_[1-5]"]) is int + Review.model_validate({ + "key_issues_to_review": [], + "estimated_effort_to_review_[1-5]": load_yaml(text)["estimated_effort_to_review_[1-5]"], + }) + + +def test_ranking_example_is_a_valid_numeric_payload(): + text = 'which_response_was_better: 1\nwhy: "It is clearer."\nscore_response1: 9\nscore_response2: 7' + parsed = load_yaml(text) + assert isinstance(parsed["which_response_was_better"], int) + PRRankResponses.model_validate(parsed) From 3fc1a6dbc089525e21ff9b8f96c1dcf8fa4e604a Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Tue, 15 Sep 2026 00:02:22 +0800 Subject: [PATCH 103/238] fix(github-app): add opt-in webhook delivery deduplication (#3374) --- .../usage-guide/configuration_reference.md | 1 + pr_agent/servers/github_app.py | 84 +++++-- pr_agent/settings/configuration.toml | 6 + tests/unittest/test_config_reference.py | 2 +- .../unittest/test_github_webhook_delivery.py | 230 ++++++++++++++++++ .../test_github_webhook_delivery_failures.py | 142 +++++++++++ 6 files changed, 441 insertions(+), 24 deletions(-) create mode 100644 tests/unittest/test_github_webhook_delivery.py create mode 100644 tests/unittest/test_github_webhook_delivery_failures.py diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 904f347725..1038d3f209 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -338,6 +338,7 @@ _This section only documents commented-out examples; see the [TOML source](https | `review_states` | ["changes_requested"] | A submitted GitHub review can optionally trigger these commands. The empty default preserves current behavior. | | `review_author_types` | ["User"] | | | `review_commands` | [] | | +| `webhook_delivery_deduplication` | false | Opt in to in-memory deduplication by X-GitHub-Delivery within each worker process. Active work stays protected; completed IDs expire after the deployment's push_trigger_pending_tasks_ttl (read at startup, 300 seconds by default). Failed or cancelled work can retry immediately. Repeated deliveries do not extend the TTL. Manual redeliveries are also suppressed during this window. State is cleared on restart and is not shared across workers or replicas. | | `handle_push_trigger` | false | settings for "pull_request" event with "synchronize" action - used to detect and handle push triggers for new commits | | `push_trigger_ignore_bot_commits` | true | | | `push_trigger_ignore_merge_commits` | true | | diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index 9907f885cd..aad72ded91 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -1,6 +1,7 @@ import copy import os import re +import time import uuid from typing import Any, Dict, Tuple @@ -18,7 +19,7 @@ from pr_agent.identity_providers import get_identity_provider from pr_agent.identity_providers.identity_provider import Eligibility from pr_agent.log import LoggingFormat, get_logger, setup_logger -from pr_agent.servers.utils import get_pr_commands, push_trigger_slot, verify_signature +from pr_agent.servers.utils import DefaultDictWithTimeout, get_pr_commands, push_trigger_slot, verify_signature from pr_agent.telemetry.prometheus import attach_metrics_endpoint, prometheus_metrics_enabled setup_logger(fmt=LoggingFormat.JSON, level=get_settings().get("CONFIG.LOG_LEVEL", "DEBUG")) @@ -30,6 +31,12 @@ else: build_number = "unknown" router = APIRouter() +_WEBHOOK_DELIVERY_TTL = get_settings().github_app.push_trigger_pending_tasks_ttl +_completed_webhook_deliveries = DefaultDictWithTimeout( + float, + ttl=_WEBHOOK_DELIVERY_TTL, + update_key_time_on_get=False, +) @router.post("/api/v1/github_webhooks") @@ -47,7 +54,12 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req context["installation_id"] = installation_id context["settings"] = copy.deepcopy(global_settings) context["git_provider"] = {} - background_tasks.add_task(handle_request, body, event=request.headers.get("X-GitHub-Event", None)) + background_tasks.add_task( + handle_request, + body, + event=request.headers.get("X-GitHub-Event", None), + delivery_id=request.headers.get("X-GitHub-Delivery", None), + ) return {} @@ -137,6 +149,7 @@ async def handle_comments_on_pr(body: Dict[str, Any], # Optional, and disabled by default: tell the author how the command ended without # adding another comment to the thread. provider.react_to_outcome(comment_id, bool(succeeded)) + return succeeded else: get_logger().info(f"User {sender=} is not eligible to process comment on PR {api_url=}") @@ -157,7 +170,7 @@ async def handle_new_pr_opened(body: Dict[str, Any], # logic to ignore PRs with specific titles (e.g. "[Auto] ...") apply_repo_settings(api_url) if get_identity_provider().verify_eligibility("github", sender_id, api_url) is not Eligibility.NOT_ELIGIBLE: - await _perform_auto_commands_github("pr_commands", agent, body, api_url, log_context) + return await _perform_auto_commands_github("pr_commands", agent, body, api_url, log_context) else: get_logger().info(f"User {sender=} is not eligible to process PR {api_url=}") @@ -229,7 +242,7 @@ async def handle_pull_request_review_submitted(body: Dict[str, Any], return {} if get_identity_provider().verify_eligibility("github", sender_id, api_url) is not Eligibility.NOT_ELIGIBLE: - await _perform_auto_commands_github("review_commands", agent, body, api_url, log_context) + return await _perform_auto_commands_github("review_commands", agent, body, api_url, log_context) else: get_logger().info(f"User {sender=} is not eligible to process review on PR {api_url=}") @@ -267,7 +280,7 @@ async def handle_push_trigger_for_new_commits(body: Dict[str, Any], return {} if get_identity_provider().verify_eligibility("github", sender_id, api_url) is not Eligibility.NOT_ELIGIBLE: get_logger().info(f"Performing incremental review for {api_url=} because of {event=} and {action=}") - await _perform_auto_commands_github("push_commands", agent, body, api_url, log_context) + return await _perform_auto_commands_github("push_commands", agent, body, api_url, log_context) def handle_closed_pr(body, event, action, log_context): @@ -376,19 +389,8 @@ def should_process_pr_logic(body) -> bool: return True -async def handle_request(body: Dict[str, Any], event: str): - """ - Handle incoming GitHub webhook requests. - - Args: - body: The request body. - event: The GitHub event type (e.g. "pull_request", "issue_comment", etc.). - """ - action = body.get("action") # "created", "opened", "reopened", "ready_for_review", "review_requested", "synchronize" - get_logger().debug(f"Handling request with event: {event}, action: {action}") - if not action: - get_logger().debug("No action found in request body, exiting handle_request") - return {} +async def _dispatch_request(body: Dict[str, Any], event: str, action: str): + """Return False when a command fails, preserving ignored events as completed work.""" agent = PRAgent() log_context, sender, sender_id, sender_type = get_log_context(body, event, action, build_number) @@ -407,22 +409,22 @@ async def handle_request(body: Dict[str, Any], event: str): # handle submitted pull request reviews elif event == 'pull_request_review' and action == 'submitted': get_logger().debug('Request body', artifact=body, event=event) - await handle_pull_request_review_submitted( + return await handle_pull_request_review_submitted( body, event, sender, sender_id, sender_type, action, log_context, agent ) # handle comments on PRs elif action == 'created': get_logger().debug('Request body', artifact=body, event=event) - await handle_comments_on_pr(body, event, sender, sender_id, action, log_context, agent) + return await handle_comments_on_pr(body, event, sender, sender_id, action, log_context, agent) # handle new PRs elif event == 'pull_request' and action != 'synchronize' and action != 'closed': get_logger().debug('Request body', artifact=body, event=event) - await handle_new_pr_opened(body, event, sender, sender_id, action, log_context, agent) + return await handle_new_pr_opened(body, event, sender, sender_id, action, log_context, agent) elif event == "issue_comment" and 'edited' in action: pass # handle_checkbox_clicked # handle pull_request event with synchronize action - "push trigger" for new commits elif event == 'pull_request' and action == 'synchronize': - await handle_push_trigger_for_new_commits(body, event, sender,sender_id, action, log_context, agent) + return await handle_push_trigger_for_new_commits(body, event, sender,sender_id, action, log_context, agent) elif event == 'pull_request' and action == 'closed': if get_settings().get("CONFIG.ANALYTICS_FOLDER", ""): handle_closed_pr(body, event, action, log_context) @@ -431,6 +433,38 @@ async def handle_request(body: Dict[str, Any], event: str): return {} +async def handle_request(body: Dict[str, Any], event: str, delivery_id: str | None = None): + """ + Handle incoming GitHub webhook requests. + + Args: + body: The request body. + event: The GitHub event type (e.g. "pull_request", "issue_comment", etc.). + delivery_id: GitHub's stable identifier for this webhook delivery and its redeliveries. + """ + action = body.get("action") # "created", "opened", "reopened", "ready_for_review", "review_requested", "synchronize" + get_logger().debug(f"Handling request with event: {event}, action: {action}") + if not action: + get_logger().debug("No action found in request body, exiting handle_request") + return {} + settings = get_settings() + if not delivery_id or not settings.get("GITHUB_APP.WEBHOOK_DELIVERY_DEDUPLICATION", False): + await _dispatch_request(body, event, action) + return {} + + async with push_trigger_slot( + f"github-webhook-delivery:{delivery_id}", + allow_backlog=False, + ttl=_WEBHOOK_DELIVERY_TTL, + ) as proceed: + if not proceed or _completed_webhook_deliveries[delivery_id] > time.monotonic(): + return {} + succeeded = await _dispatch_request(body, event, action) + if succeeded is not False: + _completed_webhook_deliveries[delivery_id] = time.monotonic() + _WEBHOOK_DELIVERY_TTL + return {} + + def handle_line_comments(body: Dict, comment_body: [str, Any]): if not comment_body: return "" @@ -506,13 +540,17 @@ async def _perform_auto_commands_github(commands_conf: str, agent: PRAgent, body get_logger().info(f"No {commands_conf} configured, skipping auto commands") return get_settings().set("config.is_auto_command", True) + succeeded = True for command in commands: try: new_command = prepare_command(command) get_logger().info(f"{commands_conf}. Performing auto command '{new_command}', for {api_url=}") - await agent.handle_request(api_url, new_command) + if await agent.handle_request(api_url, new_command) is False: + succeeded = False except Exception as e: get_logger().error(f"Failed to perform command {command}: {e}") + succeeded = False + return succeeded @router.get("/") diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index a372954e9f..c73ed4f96f 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -291,6 +291,12 @@ feedback_on_draft_pr = false review_states = ["changes_requested"] review_author_types = ["User"] review_commands = [] +# Opt in to in-memory deduplication by X-GitHub-Delivery within each worker process. +# Active work stays protected; completed IDs expire after the deployment's push_trigger_pending_tasks_ttl +# (read at startup, 300 seconds by default). Failed or cancelled work can retry immediately. +# Repeated deliveries do not extend the TTL. Manual redeliveries are also suppressed during this window. +# State is cleared on restart and is not shared across workers or replicas. +webhook_delivery_deduplication = false # settings for "pull_request" event with "synchronize" action - used to detect and handle push triggers for new commits handle_push_trigger = false push_trigger_ignore_bot_commits = true diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index 7b9ac8f335..d829e9a05c 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 259 + assert sum(keys.values()) == 260 assert { "model", diff --git a/tests/unittest/test_github_webhook_delivery.py b/tests/unittest/test_github_webhook_delivery.py new file mode 100644 index 0000000000..5826e30ebe --- /dev/null +++ b/tests/unittest/test_github_webhook_delivery.py @@ -0,0 +1,230 @@ +"""Tests for opt-in GitHub webhook delivery deduplication.""" + +import asyncio +import copy +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from starlette.background import BackgroundTasks +from starlette_context import request_cycle_context + +from pr_agent.config_loader import global_settings +from pr_agent.servers import github_app +from pr_agent.servers import utils as servers_utils + + +@pytest.fixture +def delivery_clock(monkeypatch): + now = [0.0] + monkeypatch.setattr( + servers_utils.DefaultDictWithTimeout, + "_DefaultDictWithTimeout__time", + staticmethod(lambda: now[0]), + ) + monkeypatch.setattr(github_app, "time", SimpleNamespace(monotonic=lambda: now[0])) + return now + + +@pytest.fixture +def delivery_context(monkeypatch, delivery_clock): + settings = copy.deepcopy(global_settings) + settings.set("GITHUB_APP.WEBHOOK_DELIVERY_DEDUPLICATION", True) + settings.set("GITHUB_APP.PUSH_TRIGGER_PENDING_TASKS_TTL", 300) + monkeypatch.setattr(github_app, "_WEBHOOK_DELIVERY_TTL", 300) + monkeypatch.setattr(servers_utils, "_push_trigger_states_by_ttl", {}) + monkeypatch.setattr(servers_utils, "_active_push_trigger_states", {}) + monkeypatch.setattr( + github_app, + "_completed_webhook_deliveries", + servers_utils.DefaultDictWithTimeout( + float, ttl=300, update_key_time_on_get=False + ), + ) + with request_cycle_context({"settings": settings}): + yield settings + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delivery_id", ["delivery-1", None]) +async def test_github_webhook_route_forwards_delivery_id(monkeypatch, delivery_id): + body = {"installation": {"id": 1}, "action": "created"} + observed = [] + + class Request: + headers = {"X-GitHub-Event": "issue_comment"} + if delivery_id is not None: + headers["X-GitHub-Delivery"] = delivery_id + + async def fake_get_body(_request): + return body + + async def fake_handle_request(*args, **kwargs): + observed.append((args, kwargs)) + + monkeypatch.setattr(github_app, "get_body", fake_get_body) + monkeypatch.setattr(github_app, "handle_request", fake_handle_request) + background_tasks = BackgroundTasks() + + with request_cycle_context({}): + await github_app.handle_github_webhooks(background_tasks, Request(), object()) + await background_tasks() + + assert observed == [ + ((body,), {"event": "issue_comment", "delivery_id": delivery_id}), + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("enabled", "delivery_id"), [(False, "delivery-1"), (True, None), (True, "")]) +async def test_handle_request_bypasses_deduplication_when_disabled_or_missing_id( + monkeypatch, delivery_context, enabled, delivery_id +): + delivery_context.set("GITHUB_APP.WEBHOOK_DELIVERY_DEDUPLICATION", enabled) + dispatch = AsyncMock(return_value={}) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + assert await github_app.handle_request(body, "issue_comment", delivery_id=delivery_id) == {} + assert await github_app.handle_request(body, "issue_comment", delivery_id=delivery_id) == {} + + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("result", [True, {}, None]) +async def test_handle_request_suppresses_completed_or_intentionally_ignored_delivery( + monkeypatch, delivery_context, result +): + dispatch = AsyncMock(return_value=result) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + + dispatch.assert_awaited_once_with(body, "issue_comment", "created") + + +@pytest.mark.asyncio +async def test_handle_request_keeps_distinct_delivery_ids_independent(monkeypatch, delivery_context): + dispatch = AsyncMock(return_value={}) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-2") + + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("elapsed", [0, 301]) +async def test_handle_request_suppresses_active_delivery_even_after_ttl( + monkeypatch, delivery_context, delivery_clock, elapsed +): + entered = asyncio.Event() + release = asyncio.Event() + + async def dispatch(_body, _event, _action): + entered.set() + await release.wait() + return {} + + dispatch_mock = AsyncMock(side_effect=dispatch) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch_mock) + body = {"action": "created"} + first = asyncio.create_task(github_app.handle_request(body, "issue_comment", delivery_id="delivery-1")) + try: + await asyncio.wait_for(entered.wait(), 1) + delivery_clock[0] += elapsed + await asyncio.wait_for( + github_app.handle_request(body, "issue_comment", delivery_id="delivery-1"), 1 + ) + dispatch_mock.assert_awaited_once() + finally: + release.set() + await asyncio.wait_for(first, 1) + + assert not servers_utils._active_push_trigger_states + + +@pytest.mark.asyncio +async def test_active_delivery_does_not_block_a_different_delivery(monkeypatch, delivery_context): + entered = asyncio.Event() + release = asyncio.Event() + + async def dispatch(body, _event, _action): + if body["comment_id"] == 1: + entered.set() + await release.wait() + return {} + + dispatch_mock = AsyncMock(side_effect=dispatch) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch_mock) + first = asyncio.create_task( + github_app.handle_request({"action": "created", "comment_id": 1}, "issue_comment", delivery_id="delivery-1") + ) + try: + await asyncio.wait_for(entered.wait(), 1) + await asyncio.wait_for( + github_app.handle_request({"action": "created", "comment_id": 2}, "issue_comment", delivery_id="delivery-2"), + 1, + ) + assert dispatch_mock.await_count == 2 + finally: + release.set() + await asyncio.wait_for(first, 1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error", [RuntimeError("agent failed"), asyncio.CancelledError()]) +async def test_failed_or_cancelled_dispatch_can_retry(monkeypatch, delivery_context, error): + dispatch = AsyncMock(side_effect=[error, {}]) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + with pytest.raises(type(error)): + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert not servers_utils._active_push_trigger_states + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +async def test_duplicate_does_not_extend_completed_delivery_ttl(monkeypatch, delivery_context, delivery_clock): + dispatch = AsyncMock(return_value={}) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + delivery_clock[0] = 299 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch.await_count == 1 + + delivery_clock[0] = 301 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +async def test_completed_delivery_ttl_starts_after_dispatch(monkeypatch, delivery_context, delivery_clock): + async def dispatch(_body, _event, _action): + delivery_clock[0] += 301 + return {} + + dispatch_mock = AsyncMock(side_effect=dispatch) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch_mock) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + delivery_clock[0] = 600 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch_mock.await_count == 1 + + delivery_clock[0] = 602 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch_mock.await_count == 2 diff --git a/tests/unittest/test_github_webhook_delivery_failures.py b/tests/unittest/test_github_webhook_delivery_failures.py new file mode 100644 index 0000000000..6df03c21ca --- /dev/null +++ b/tests/unittest/test_github_webhook_delivery_failures.py @@ -0,0 +1,142 @@ +"""Exercise failed delivery retries through the real GitHub event handlers.""" + +import asyncio +import copy +from unittest.mock import AsyncMock, Mock, call + +import pytest +from starlette_context import request_cycle_context + +from pr_agent.config_loader import get_settings +from pr_agent.identity_providers.identity_provider import Eligibility +from pr_agent.servers import github_app +from pr_agent.servers import utils as servers_utils + +EVENTS = [ + ("issue_comment", "created"), + ("pull_request", "opened"), + ("pull_request_review", "submitted"), + ("pull_request", "synchronize"), +] +AUTO_EVENTS = EVENTS[1:] +PR_URL = "https://api.github.com/repos/org/repo/pulls/1" + + +@pytest.fixture +def delivery_agent(monkeypatch): + settings = copy.deepcopy(get_settings()) + settings.set("GITHUB_APP.WEBHOOK_DELIVERY_DEDUPLICATION", True) + settings.set("GITHUB_APP.HANDLE_PR_ACTIONS", ["opened"]) + settings.set("GITHUB_APP.HANDLE_PUSH_TRIGGER", True) + settings.set("GITHUB_APP.PUSH_TRIGGER_IGNORE_MERGE_COMMITS", False) + settings.set("GITHUB_APP.FEEDBACK_ON_DRAFT_PR", False) + settings.set("GITHUB_APP.REVIEW_AUTHOR_TYPES", ["User"]) + settings.set("GITHUB_APP.REVIEW_STATES", ["changes_requested"]) + settings.set("GITHUB_APP.PR_COMMANDS", ["/review"]) + settings.set("GITHUB_APP.REVIEW_COMMANDS", ["/review"]) + settings.set("GITHUB_APP.PUSH_COMMANDS", ["/review"]) + settings.set("CONFIG.DISABLE_AUTO_FEEDBACK", False) + agent = Mock(handle_request=AsyncMock(return_value=True)) + identity = Mock() + identity.verify_eligibility.return_value = Eligibility.ELIGIBLE + monkeypatch.setattr(github_app, "PRAgent", lambda: agent) + monkeypatch.setattr(github_app, "get_identity_provider", lambda: identity) + monkeypatch.setattr(github_app, "get_git_provider_with_context", lambda **kwargs: Mock()) + monkeypatch.setattr(github_app, "apply_repo_settings", lambda api_url: None) + monkeypatch.setattr(servers_utils, "_push_trigger_states_by_ttl", {}) + monkeypatch.setattr(servers_utils, "_active_push_trigger_states", {}) + monkeypatch.setattr( + github_app, + "_completed_webhook_deliveries", + servers_utils.DefaultDictWithTimeout( + float, ttl=github_app._WEBHOOK_DELIVERY_TTL, update_key_time_on_get=False + ), + ) + with request_cycle_context({"settings": settings}): + yield agent, "delivery-1" + + +def _body(action): + return { + "action": action, + "installation": {"id": 1}, + "sender": {"id": 2, "login": "user", "type": "User"}, + "repository": {"full_name": "org/repo"}, + "pull_request": { + "url": PR_URL, + "state": "open", + "draft": False, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + }, + "comment": {"body": "/review", "pull_request_url": PR_URL, "id": 42}, + "review": {"state": "changes_requested", "user": {"type": "User"}}, + "before": "before-sha", + "after": "after-sha", + } + + +@pytest.mark.parametrize(("event", "action"), EVENTS) +async def test_false_agent_result_can_be_retried(delivery_agent, event, action): + agent, delivery_id = delivery_agent + agent.handle_request.side_effect = [False, True] + body = _body(action) + + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + assert agent.handle_request.await_count == 2 + + +@pytest.mark.parametrize(("event", "action"), AUTO_EVENTS) +@pytest.mark.parametrize("failure", [False, RuntimeError("agent failed")], ids=["false", "exception"]) +async def test_auto_commands_continue_after_failure_and_allow_retry(delivery_agent, event, action, failure): + agent, delivery_id = delivery_agent + for commands in ("PR_COMMANDS", "REVIEW_COMMANDS", "PUSH_COMMANDS"): + get_settings().set(f"GITHUB_APP.{commands}", ["/review", "/describe"]) + agent.handle_request.side_effect = [failure, True, True, True] + body = _body(action) + + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + assert agent.handle_request.await_args_list == [ + call(PR_URL, ["/review"]), + call(PR_URL, ["/describe"]), + call(PR_URL, ["/review"]), + call(PR_URL, ["/describe"]), + ] + + +@pytest.mark.parametrize(("event", "action"), EVENTS) +async def test_cancelled_agent_call_propagates_and_allows_retry(delivery_agent, event, action): + agent, delivery_id = delivery_agent + agent.handle_request.side_effect = [asyncio.CancelledError(), True] + body = _body(action) + + with pytest.raises(asyncio.CancelledError): + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + assert agent.handle_request.await_count == 2 + + +@pytest.mark.parametrize(("event", "action"), EVENTS) +async def test_intentionally_ignored_delivery_is_completed(delivery_agent, monkeypatch, event, action): + agent, delivery_id = delivery_agent + body = _body(action) + if event == "issue_comment": + body["comment"]["body"] = "Thanks for the review" + else: + body["pull_request"]["draft"] = True + dispatch = AsyncMock(wraps=github_app._dispatch_request) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + dispatch.assert_awaited_once() + agent.handle_request.assert_not_awaited() From 8ac8f7aa9bb97ecf3aa050aac9f6462314206692 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:15:47 +0545 Subject: [PATCH 104/238] feat(context): load repo-context files from sibling repos (same namespace/owner) (#3362) --- .../usage-guide/additional_configurations.md | 35 + .../usage-guide/configuration_reference.md | 4 +- pr_agent/algo/cli_args.py | 15 +- pr_agent/algo/repo_context.py | 208 ++- pr_agent/config_security.py | 21 + pr_agent/git_providers/git_provider.py | 34 + pr_agent/git_providers/github_provider.py | 84 +- pr_agent/git_providers/gitlab_provider.py | 101 +- pr_agent/servers/github_app.py | 2 + pr_agent/servers/gitlab_webhook.py | 8 + pr_agent/settings/configuration.toml | 4 +- tests/unittest/test_apply_repo_settings.py | 43 + tests/unittest/test_cli_args_security.py | 15 + tests/unittest/test_config_reference.py | 2 +- tests/unittest/test_repo_context.py | 1164 ++++++++++++++++- 15 files changed, 1694 insertions(+), 46 deletions(-) diff --git a/docs/docs/usage-guide/additional_configurations.md b/docs/docs/usage-guide/additional_configurations.md index 939910c5fe..cae1b8f909 100644 --- a/docs/docs/usage-guide/additional_configurations.md +++ b/docs/docs/usage-guide/additional_configurations.md @@ -436,6 +436,41 @@ To bound how much of this context is sent to the model, `repo_context_max_lines` repo_context_max_lines = 500 ``` +### Context from sibling repositories + +The host operator must first approve repositories in the deployment configuration: + +```toml +[config] +repo_context_sibling_repos = ["my-group/library"] +repo_context_max_sibling_files = 5 +``` + +Approve only repositories whose contents may appear in reviews of consuming repositories. +This allowlist and the fetch limit cannot be changed by repository settings or comment arguments. +An empty allowlist disables sibling reads. + +A consuming repository can then select files in its `.pr_agent.toml` as structured +`repo_context_files` entries: + +```toml +[config] +repo_context_files = [ + "AGENTS.md", + {repo_id = "my-group/library", file_path = "src/api.py"}, +] +``` + +Use `owner/repository` on GitHub or a full project path on GitLab. GitLab also accepts numeric +project IDs as strings, provided the same identifier is in the host allowlist. Resolved repositories +must share the current repository's owning namespace: the GitHub owner or GitLab top-level group, +including projects in different subgroups. Renamed or redirected paths must be updated to their +canonical names in both settings. + +Sibling files always come from their default branch and share `repo_context_max_lines` with local +files. Private and internal repositories also require requester access. Comment arguments cannot +override `repo_context_files` at all; a repository's `.pr_agent.toml` may still set it. + ## Ignoring automatic commands in PRs PR-Agent allows you to automatically ignore certain PRs based on various criteria: diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 1038d3f209..17127665f4 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -56,9 +56,11 @@ to-do list. | `skip_keys` | [] | | | `custom_reasoning_model` | false | when true, disables system messages and temperature controls for models that don't support chat-style inputs | | `response_language` | "en-US" | Language locales code for PR responses in ISO 3166 and ISO 639 format (e.g., "en-US", "it-IT", "zh-CN", ...) | -| `repo_context_files` | ["AGENTS.md"] | Repository-relative files (e.g. AGENTS.md, CLAUDE.md) to include as AI prompt context; set to [] to disable | +| `repo_context_files` | ["AGENTS.md"] | Repository-relative files (e.g. AGENTS.md, CLAUDE.md) to include as AI prompt context; set to [] to disable local context. A structured entry {"repo_id" = ..., "file_path" = ...} selects a sibling default-branch file from the same namespace/owner; repo_id must be in the host-issued repo_context_sibling_repos allowlist below. Reads use the sibling default branch and share repo_context_max_lines; repository settings may select entries, comment arguments cannot override this key | | `repo_context_from_default_branch` | true | Read repo context files from the repository default branch (trusts only default-branch content). Set to false to read from the PR target branch instead. | | `repo_context_max_lines` | 500 | Maximum total rendered lines for repo context, including wrapper tags | +| `repo_context_sibling_repos` | [] | Host-only list of approved sibling repository identifiers (GitHub owner/repo, GitLab group/project or numeric ID strings) that repo_context_files sibling entries may select. Empty disables sibling reads. Approve only repositories whose content may be disclosed in consuming PRs, because the actor check bounds who triggers a read, not who chose the target or where the output lands. Repository settings and comment arguments cannot change this list. Canonical identities and owning namespaces are checked after resolution | +| `repo_context_max_sibling_files` | 5 | Maximum number of sibling-repository files fetched per repo-context build. The fetch count is bounded separately from repo_context_max_lines so selected sibling files cannot trigger an unbounded number of cross-repository calls; sibling files still compete for the repo_context_max_lines budget. Host-only (cannot be raised by a repository's .pr_agent.toml or a comment command) and clamped to a hard ceiling of 20 fetches per build. | **token limits** | Key | Default | Description | diff --git a/pr_agent/algo/cli_args.py b/pr_agent/algo/cli_args.py index e5551efbfb..cceac7bdfe 100644 --- a/pr_agent/algo/cli_args.py +++ b/pr_agent/algo/cli_args.py @@ -1,16 +1,22 @@ from base64 import b64decode -from pr_agent.config_security import REPO_HOST_ONLY_KEYS_BY_SECTION, REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION +from pr_agent.config_security import ( + CLI_HOST_ONLY_KEYS_BY_SECTION, + REPO_HOST_ONLY_KEYS_BY_SECTION, + REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION, +) class CliArgs: @staticmethod def _host_only_setting_arg(arg: str) -> str | None: """Return a protected setting token when a CLI arg targets a host-only key.""" - setting_name = arg.removeprefix('--').split('=', 1)[0].replace('__', '.') + setting_name = arg.lstrip('-').split('=', 1)[0].strip().replace('__', '.') section, separator, key = setting_name.partition('.') if not separator: - if section in REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION or section in REPO_HOST_ONLY_KEYS_BY_SECTION: + if (section in REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION + or section in REPO_HOST_ONLY_KEYS_BY_SECTION + or section in CLI_HOST_ONLY_KEYS_BY_SECTION): return f'.{section}' return None @@ -20,6 +26,9 @@ def _host_only_setting_arg(arg: str) -> str | None: host_only_keys = REPO_HOST_ONLY_KEYS_BY_SECTION.get(section, frozenset()) if key.split('.', 1)[0] in host_only_keys: return f'.{section}.{key}' + cli_host_only_keys = CLI_HOST_ONLY_KEYS_BY_SECTION.get(section, frozenset()) + if key.split('.', 1)[0] in cli_host_only_keys: + return f'.{section}.{key}' return None @staticmethod diff --git a/pr_agent/algo/repo_context.py b/pr_agent/algo/repo_context.py index 4b19c4a2db..12efb8ec76 100644 --- a/pr_agent/algo/repo_context.py +++ b/pr_agent/algo/repo_context.py @@ -14,6 +14,13 @@ REPO_CONTEXT_CACHE_ATTRIBUTE = "_repo_context_cache" REPO_CONTEXT_CACHE_MAX_SIZE = 256 REPO_CONTEXT_CACHE_TTL_SECONDS = 15 * 60 +_DEFAULT_MAX_SIBLING_CONTEXT_FILES = 5 +# Absolute host-controlled ceiling on sibling-repo context fetches per build. The operator-facing +# repo_context_max_sibling_files setting is clamped to this value before any fetch begins, so a +# comment command or an unexpectedly large configured value cannot produce unbounded cross-repo +# API calls. It is intentionally not user-configurable. +_HARD_MAX_SIBLING_CONTEXT_FILES = 20 +_SIBLING_REPO_SEPARATOR = ":" _REPO_CONTEXT_CACHE_MISS = object() _unsupported_repo_context_provider_classes = set() @@ -87,9 +94,6 @@ def _get_repo_context_process_cache_key( def _get_repo_context_config() -> tuple[list, int] | None: context_files = get_settings().config.get("repo_context_files", []) - if not context_files: - return None - if isinstance(context_files, str): get_logger().warning( "repo_context_files should be a list of file paths; treating string value as one file path", @@ -103,6 +107,9 @@ def _get_repo_context_config() -> tuple[list, int] | None: ) return None + if not context_files: + return None + max_lines = get_settings().config.get("repo_context_max_lines", 500) try: max_lines = max(0, int(max_lines)) @@ -127,6 +134,51 @@ def _provider_supports_repo_context(git_provider) -> bool: return False +def _provider_supports_sibling_repo_context(git_provider) -> bool: + provider_method = getattr(type(git_provider), "get_sibling_repo_file_content", None) + return provider_method is not None and provider_method is not GitProvider.get_sibling_repo_file_content + + +def _sibling_repo_context_entries(context_files: list) -> list[tuple[str, str]]: + """Return the (repo_id, file_path) pairs that parse as sibling entries. + + Only entries that would really fetch from a sibling repository may force a cache bypass + (build_repo_context), so this parses with the same rules the loader uses: malformed or + non-sibling entries yield no pair and keep the revision-keyed cache usable. Whether the + provider can resolve siblings is decided separately by _provider_supports_sibling_repo_context. + """ + sibling_entries = [] + for entry in context_files: + repo_id, file_path = _parse_repo_context_file_entry(entry) + if repo_id is not None and file_path: + sibling_entries.append((repo_id, file_path)) + return sibling_entries + + +def _parse_repo_context_file_entry(entry: str | dict) -> tuple[str | None, str]: + """Parse a repo-context entry into (sibling repo id, file path). + + A string entry is always a file path in the current repository and yields (None, path). + A sibling entry is a structured ``{"repo_id": ..., "file_path": ...}`` dict naming a + repository in the same namespace/owner (GitLab: ``group/subgroup/project``, GitHub: + ``owner/repo``); its file is read from the sibling's default branch. A malformed entry + yields (None, "") and is skipped by the loader. + """ + if isinstance(entry, dict): + repo_id = entry.get("repo_id") + file_path = entry.get("file_path") + if not isinstance(repo_id, str) or not isinstance(file_path, str): + return None, "" + repo_id = repo_id.strip().strip("/") + file_path = file_path.strip().lstrip("/") + if not repo_id or not file_path: + return None, "" + return repo_id, file_path + if not isinstance(entry, str): + return None, "" + return None, entry.strip() + + def _get_provider_repo_context_cache(git_provider) -> _RepoContextCache: repo_context_cache = getattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, None) if repo_context_cache is None or not isinstance(repo_context_cache, _RepoContextCache): @@ -185,45 +237,120 @@ def _read_bool_setting(key: str, default: bool) -> bool: return default +def _read_max_sibling_context_files() -> int: + try: + max_siblings = int(get_settings().config.get("repo_context_max_sibling_files", + _DEFAULT_MAX_SIBLING_CONTEXT_FILES)) + except (TypeError, ValueError): + max_siblings = _DEFAULT_MAX_SIBLING_CONTEXT_FILES + # Clamp defensively so a runtime override or an unexpectedly large configured value can never + # exceed the host-controlled hard ceiling, however the value got into the settings. + return min(max(0, max_siblings), _HARD_MAX_SIBLING_CONTEXT_FILES) + + def _load_repo_context_files( git_provider, context_files: list, from_default_branch: bool | None = None -) -> tuple[dict[str, str], bool]: +) -> tuple[list[tuple[str, str]], bool]: if from_default_branch is None: from_default_branch = _read_bool_setting("repo_context_from_default_branch", default=True) - files = {} + # Ordered (label, content) entries rather than a label-keyed dict: a local path can equal a + # sibling's rendered label, and a label-keyed mapping would silently drop one of them. + files = [] had_fetch_error = False - for file_path in context_files: - if not isinstance(file_path, str) or not file_path.strip(): - get_logger().warning("Skipping invalid repo context file path", artifact={"file_path": file_path}) - continue - - file_path = file_path.strip() - try: - content = git_provider.get_repo_file_content(file_path, from_default_branch=from_default_branch) - except Exception as e: - had_fetch_error = True - get_logger().warning(f"Failed to load repo context file: {file_path}", artifact={"error": str(e)}) + max_siblings = _read_max_sibling_context_files() + sibling_fetch_attempts = 0 + seen_sibling_pairs = set() + for entry in context_files: + repo_id, file_path = _parse_repo_context_file_entry(entry) + if not file_path: + if isinstance(entry, str) and not entry.strip(): + get_logger().warning("Skipping invalid repo context file path", artifact={"file_path": entry}) + else: + get_logger().warning( + "Ignoring malformed repo context entry (expected a local file path or a " + "{'repo_id': ..., 'file_path': ...} sibling entry)", + artifact={"entry": entry}, + ) continue + if repo_id is not None: + if not _provider_supports_sibling_repo_context(git_provider): + get_logger().warning( + f"{type(git_provider).__name__} does not support sibling repository context; " + f"skipping {repo_id}:{file_path}" + ) + continue + sibling_pair = (repo_id, file_path) + if sibling_pair in seen_sibling_pairs: + # Duplicate entries must not consume the fetch cap (or the rendered budget): + # fetch each unique sibling file once and let the cap bound the real work. + get_logger().debug(f"Skipping duplicate sibling repo context file: {repo_id}:{file_path}") + continue + seen_sibling_pairs.add(sibling_pair) + if sibling_fetch_attempts >= max_siblings: + get_logger().warning( + f"Stopping repo context at {max_siblings} sibling fetch attempt(s) (" + f"config.repo_context_max_sibling_files); skipping {repo_id}:{file_path}" + ) + continue + sibling_fetch_attempts += 1 + try: + content = git_provider.get_sibling_repo_file_content(repo_id, file_path) + except Exception: + had_fetch_error = True + # A sibling fetch can fail after the provider resolved ids, so keep the full + # traceback; a flat warning hides which request failed and why. + get_logger().exception( + f"Failed to load sibling repo context file: {repo_id}:{file_path}" + ) + continue + # Render the file under its sibling path so the model sees where it came from. + label = f"{repo_id}/{file_path}" + else: + if isinstance(entry, str) and _SIBLING_REPO_SEPARATOR in entry: + # A ':' inside a plain local path used to read as a sibling entry. Structured + # {"repo_id", "file_path"} dicts are the only sibling form now, so hint at the + # migration instead of silently splitting on ':'. + get_logger().warning( + "repo context file path contains ':' and is read as a local file; use a " + "structured {'repo_id': ..., 'file_path': ...} entry in repo_context_files to " + "load a sibling repository file", + artifact={"file_path": entry}, + ) + try: + content = git_provider.get_repo_file_content(file_path, from_default_branch=from_default_branch) + except Exception as e: + had_fetch_error = True + get_logger().warning(f"Failed to load repo context file: {file_path}", artifact={"error": str(e)}) + continue + label = file_path if not content: - get_logger().debug(f"Repo context file is empty or missing: {file_path}") + get_logger().debug(f"Repo context file is empty or missing: {label}") continue if isinstance(content, bytes): content = content.decode("utf-8", errors="replace") - files[file_path] = str(content).rstrip() + files.append((label, str(content).rstrip())) return files, had_fetch_error -def render_instruction_files(files: dict[str, str]) -> str: +def _repo_context_render_entries(files): + """Yield (path, content) pairs, accepting a label-keyed dict or ordered pairs. + + _load_repo_context_files returns ordered (label, content) pairs so a local path and a + sibling's rendered label may coexist; a dict spelling is kept for direct callers.""" + return files.items() if isinstance(files, dict) else files + + +def render_instruction_files(files: dict[str, str] | list[tuple[str, str]]) -> str: parts = [ INSTRUCTION_FILES_INTRO, "", ] - for path, content in files.items(): + for path, content in _repo_context_render_entries(files): scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root" fence = _get_markdown_fence(content) parts.append(f'') @@ -237,7 +364,9 @@ def render_instruction_files(files: dict[str, str]) -> str: return "\n".join(parts) -def render_instruction_files_with_line_budget(files: dict[str, str], max_lines: int) -> str: +def render_instruction_files_with_line_budget( + files: dict[str, str] | list[tuple[str, str]], max_lines: int +) -> str: parts = [ INSTRUCTION_FILES_INTRO, "", @@ -246,7 +375,7 @@ def render_instruction_files_with_line_budget(files: dict[str, str], max_lines: if max_lines < len(parts) + 1: return "" - for path, content in files.items(): + for path, content in _repo_context_render_entries(files): scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root" fence = _get_markdown_fence(content) file_header = [ @@ -289,15 +418,32 @@ def build_repo_context(git_provider) -> str: if not _provider_supports_repo_context(git_provider): return "" - from_default_branch = _read_bool_setting("repo_context_from_default_branch", default=True) - # Resolve the revision being read once and key the cache on it: within the TTL a rebase or a - # push to the base branch must not serve file content from a commit that has since moved. - context_ref = git_provider.get_repo_context_ref(from_default_branch) - cached_repo_context = _get_cached_repo_context( - git_provider, context_files, max_lines, context_ref + # Sibling repositories change independently of the requesting repo, so the current-repo + # revision cannot reconstruct their default-branch content. Bypass the revision-keyed cache + # only when the build can actually attempt a sibling fetch. A zero effective sibling-fetch + # cap disables the fetches entirely, so a local + sibling config with cap 0 must keep the + # revision-keyed cache usable (otherwise disabled siblings would silently disable local + # caching too). Malformed entries and providers without sibling support likewise keep the + # cache usable. + has_sibling_entries = ( + bool(_sibling_repo_context_entries(context_files)) + and _provider_supports_sibling_repo_context(git_provider) + and _read_max_sibling_context_files() > 0 ) - if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS: - return cached_repo_context + + from_default_branch = _read_bool_setting("repo_context_from_default_branch", default=True) + context_ref = None + if not has_sibling_entries: + # Resolve the revision being read once and key the cache on it: within the TTL a rebase + # or a push to the base branch must not serve file content from a commit that has moved. + # A sibling-only build never needs this lookup, whose failure would otherwise abort the + # whole context build before any cross-repository file is loaded. + context_ref = git_provider.get_repo_context_ref(from_default_branch) + cached_repo_context = _get_cached_repo_context( + git_provider, context_files, max_lines, context_ref + ) + if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS: + return cached_repo_context files, had_fetch_error = _load_repo_context_files(git_provider, context_files, from_default_branch) @@ -305,6 +451,6 @@ def build_repo_context(git_provider) -> str: # Only cache when every file was fetched successfully. A transient/unexpected fetch error must # not be cached as a real result, so it is retried instead of being served until the TTL expires. - if not had_fetch_error: + if not had_fetch_error and not has_sibling_entries: _store_repo_context(git_provider, context_files, max_lines, context_ref, repo_context) return repo_context diff --git a/pr_agent/config_security.py b/pr_agent/config_security.py index b6e693be39..2dfe762b05 100644 --- a/pr_agent/config_security.py +++ b/pr_agent/config_security.py @@ -30,6 +30,27 @@ # disclosed in a PR comment, so the PR author must not be able to enable it. REPO_HOST_ONLY_KEYS_BY_SECTION = { "pr_reviewer": frozenset({"publish_error_details"}), + # repo_context_sibling_repos lists the sibling repositories whose files a consuming repo + # (or a comment command) may select into model context. A repo's .pr_agent.toml alone must + # not be able to name an arbitrary same-owner private sibling: the actor check bounds who + # triggers the read, not who chose the target or where the output lands, so a sibling + # collaborator running /review on a public repo would otherwise print the sibling's private + # content into that public thread. The list stays host-only (default empty). + # repo_context_max_sibling_files bounds sibling-repository fetches per repo-context build. + # Letting a repo's .pr_agent.toml or a comment command raise it would defeat the safety + # bound and let a commenter force unbounded cross-repository API calls; it stays host-only. + "config": frozenset({"repo_context_max_sibling_files", "repo_context_sibling_repos"}), +} + +# Keys that repositories may still configure from their own default-branch settings but that +# comment/CLI *arguments* must never override. repo_context_files selects which repository and +# sibling files are fetched and rendered as the model's instruction context, so an untrusted +# commenter must not be able to point the bot at arbitrary sibling repo content. Values curated +# by the repo's maintainers in .pr_agent.toml stay accepted (apply_repo_settings does not consult +# this map); only CliArgs.validate_user_args enforces it, so repo settings and comment args do +# not drift. +CLI_HOST_ONLY_KEYS_BY_SECTION = { + "config": frozenset({"repo_context_files"}), } # Keys a per-directory `.pr_agent.toml` can never override, even when their section is diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 69df2b0a6f..00ab7f8b1f 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -546,6 +546,40 @@ def _fetch_global_repo_settings(self, namespace: str): def get_repo_file_content(self, file_path: str, from_default_branch: bool = False): return "" + def get_sibling_repo_file_content(self, repo_id: str, file_path: str, from_default_branch: bool = False): + """Fetch a single file from a sibling repository in the same namespace/owner. + + Used by repo context when a repo_context_files entry is a + sibling dict ``{"repo_id": ..., "file_path": ...}``. Only providers that can resolve + the sibling through their own authenticated API (GitHub, GitLab) override this; both + require host allowlisting, check the resolved owner/group, and read from the + sibling's default branch. The default returns "" so unsupported providers degrade + gracefully without reaching an unrelated repository or host. + """ + return "" + + def is_sibling_repo_allowed(self, repo_id: str, *, case_sensitive: bool = True) -> bool: + """Require explicit host approval before resolving a sibling repository.""" + allowed = get_settings().config.get("repo_context_sibling_repos", []) + if not isinstance(allowed, list): + return False + normalize = (lambda value: value) if case_sensitive else str.casefold + return normalize(repo_id) in { + normalize(value.strip().strip("/")) for value in allowed if isinstance(value, str) and value.strip() + } + + def set_command_actor(self, actor) -> None: + """Record the authenticated user who triggered the current command. + + Comment commands can pass arbitrary arguments, so sibling-repo context must be + authorized against the actor who issued the command rather than the PR/MR author: + a commenter may not have the read access the author has. Providers that resolve + siblings through their own authenticated API use this identity (when set) instead + of the PR/MR author. When no trustworthy actor is available the providers fail + closed for non-public siblings. + """ + self._command_actor = actor + def get_repo_context_ref(self, from_default_branch: bool = False) -> Optional[str]: """Return the ref (commit SHA or branch name) that repo-context files are read from. diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index bc595a1f4b..1aa82abdb2 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1167,12 +1167,13 @@ def get_pr_owner_id(self) -> str | None: return None return self.repo.split('/')[0] - def get_owning_namespace(self) -> Optional[str]: + def get_owning_namespace(self, *, resolved: bool = False) -> Optional[str]: # Be robust to providers built without full __init__ (e.g. __new__ in tests/helpers): # without a repo there is no org to resolve, so skip global settings quietly. if not getattr(self, "repo", None): return None - return self.repo.split('/')[0] + repo_path = self.github_client.get_repo(self.repo).full_name if resolved else self.repo + return repo_path.split("/")[0] if isinstance(repo_path, str) and "/" in repo_path else None def get_pr_description_full(self): return self.pr.body @@ -1376,6 +1377,85 @@ def get_repo_file_content(self, file_path: str, from_default_branch: bool = Fals return "" raise + def get_sibling_repo_file_content(self, repo_id: str, file_path: str, from_default_branch: bool = False): + try: + repo_id = (repo_id or "").strip().strip("/") + file_path = (file_path or "").strip().lstrip("/") + if not repo_id or not file_path: + return "" + if not self.is_sibling_repo_allowed(repo_id, case_sensitive=False): + get_logger().warning(f"Ignoring sibling repo absent from the host allowlist: {repo_id}") + return "" + sibling_repo = self.github_client.get_repo(repo_id) + resolved_name = sibling_repo.full_name + current_owner = self.get_owning_namespace(resolved=True) + # Reject redirects/transfers unless the canonical repository was explicitly selected. + if (not isinstance(resolved_name, str) or resolved_name.casefold() != repo_id.casefold() + or not current_owner or resolved_name.split("/")[0].casefold() != current_owner.casefold()): + get_logger().warning(f"Ignoring out-of-owner sibling repo in repo context: {repo_id}") + return "" + if not self._requester_can_read_sibling_repo(sibling_repo): + get_logger().warning( + f"Ignoring sibling repo context file the review requester cannot read: {repo_id}" + ) + return "" + # The sibling has no PR-target ref in this repo, so its default branch is the only + # well-defined revision to read the file from. + contents = sibling_repo.get_contents(file_path).decoded_content + if isinstance(contents, bytes): + return contents.decode("utf-8", errors="replace") + return contents + except GithubException as e: + # A missing optional file is an expected "no context" outcome; transient errors + # propagate so repo context treats them as a fetch error and does not cache empties. + if e.status == 404: + return "" + raise + + def _requester_can_read_sibling_repo(self, sibling_repo) -> bool: + # Only repositories any review requester can read are granted unconditionally: a repo + # that reports no visibility and is not flagged private (i.e. public). Internal repos + # (GitHub Enterprise) are not ``private`` but are restricted to org members (and the + # outside collaborators they add), so they must be verified instead of treated as public. + visibility = getattr(sibling_repo, "visibility", None) + is_private = bool(getattr(sibling_repo, "private", False)) + if visibility == "internal": + is_private = True + if not is_private: + return True + # Private or internal: the requester must have read access. Prefer the authenticated + # command actor when one is known; otherwise (CLI runs) fall back to the PR author as + # the operator proxy, and fail closed when neither is available. + requester_login = getattr(self, "_command_actor", None) + if not requester_login: + pr = getattr(self, "pr", None) + user = getattr(pr, "user", None) if pr is not None else None + requester_login = user.get("login") if isinstance(user, dict) else getattr(user, "login", None) + if not requester_login: + return False + if getattr(getattr(sibling_repo, "owner", None), "login", None) == requester_login: + return True + if visibility == "internal": + # Every member of the owning organization can read an internal repository without a + # per-repo grant. A definitive "not a member" is *not* denial here: outside + # collaborators can be granted access to internal repositories, so fall through to + # the collaborator check on a False answer. + organization = getattr(sibling_repo, "organization", None) + if organization is not None: + try: + if bool(organization.has_in_members(self.github_client.get_user(requester_login))): + return True + except GithubException as e: + # A 404 means the organization itself cannot be resolved, so it is not a + # verdict about the requester; keep the fall-through. Auth/platform failures + # are not denials either and must surface as a fetch error, not a silent skip. + if e.status != 404: + raise + # has_in_collaborators() answers False for a definitive non-collaborator and raises for + # auth/platform failures; both are authoritative here, so transient errors propagate and + # repo context records a fetch error instead of silently dropping the sibling file. + return bool(sibling_repo.has_in_collaborators(requester_login)) + def get_repo_context_ref(self, from_default_branch: bool = False) -> Optional[str]: # Match get_repo_file_content: the PR target (base) commit is the cached revision. # When the default branch is read (explicitly, or because no PR base exists) resolve diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 8fed65583a..e8208de700 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -143,6 +143,11 @@ def __init__(self, note, mr_web_url: Optional[str] = None): self.anchor_time = max(candidates) if candidates else None self.html_url = f"{mr_web_url}#note_{self.id}" if mr_web_url else "" +# GitLab project access levels: No access = 0, Minimal Access = 5, Guest = 10, +# Reporter = 20, Developer = 30, Maintainer = 40, Owner = 50. Reading a private or +# internal project's repository source requires at least Reporter-level access. +_GITLAB_ACCESS_LEVEL_REPORTER = 20 + class GitLabProvider(GitProvider): def __init__(self, merge_request_url: Optional[str] = None, incremental: Optional[bool] = False): @@ -1399,18 +1404,20 @@ def get_languages(self): def get_pr_branch(self): return self.mr.source_branch - def get_owning_namespace(self) -> str | None: + def get_owning_namespace(self, *, resolved: bool = False) -> str | None: # The top-level group of the project's path_with_namespace works on any host - # (gitlab.com or self-hosted) with no extra round trip: numeric project IDs are - # resolved to their canonical path first so the group name is still available. + # (gitlab.com or self-hosted). Resolve numeric IDs, and all identifiers when + # checking sibling access, to the canonical path before comparing groups. if not getattr(self, "gl", None) or not getattr(self, "id_project", None): return None project_id = str(self.id_project) - if project_id.isascii() and project_id.isdigit(): + if resolved or (project_id.isascii() and project_id.isdigit()): try: project_path = self.gl.projects.get(project_id).path_with_namespace except Exception as e: get_logger().warning(f"Failed to resolve canonical GitLab project path, error: {e}") + if resolved: + raise return None if not project_path: return None @@ -1530,6 +1537,92 @@ def get_repo_file_content(self, file_path: str, from_default_branch: bool = Fals return "" raise + def get_sibling_repo_file_content(self, repo_id: str, file_path: str, from_default_branch: bool = False): + try: + repo_id = (repo_id or "").strip().strip("/") + file_path = (file_path or "").strip().lstrip("/") + if not repo_id or not file_path: + return "" + if not self.is_sibling_repo_allowed(repo_id): + get_logger().warning(f"Ignoring sibling repo absent from the host allowlist: {repo_id}") + return "" + project = self.gl.projects.get(repo_id) + resolved_path = project.path_with_namespace + current_namespace = self.get_owning_namespace(resolved=True) + numeric_id = repo_id.isascii() and repo_id.isdigit() + identity_matches = str(project.id) == repo_id if numeric_id else resolved_path == repo_id + if (not isinstance(resolved_path, str) or "/" not in resolved_path or not identity_matches + or not current_namespace or resolved_path.split("/")[0] != current_namespace): + get_logger().warning(f"Ignoring out-of-namespace sibling repo in repo context: {repo_id}") + return "" + if not self._requester_can_read_sibling_project(project): + get_logger().warning( + f"Ignoring sibling repo context file the review requester cannot read: {repo_id}" + ) + return "" + contents = project.files.get(file_path=file_path, ref=project.default_branch).decode() + return decode_if_bytes(contents) + except GitlabGetError as e: + # A missing optional file is expected, but transient/provider failures must reach + # repo_context so the failed result is not cached as a successful empty context. + if getattr(e, "response_code", None) == 404: + return "" + raise + + def _get_review_requester_id(self) -> Optional[int]: + # Prefer the authenticated command actor when one is known: comment commands can pass + # arbitrary arguments, so sibling context must be authorized against whoever actually + # issued the command, not the MR author. Fall back to the MR author only when no actor + # is recorded (CLI runs), and fail closed without either identity. + requester_id = getattr(self, "_command_actor", None) + if requester_id: + return requester_id + mr = getattr(self, "mr", None) + author = getattr(mr, "author", None) if mr is not None else None + if isinstance(author, dict): + return author.get("id") + if author is not None: + return getattr(author, "id", None) + return None + + def _requester_can_read_sibling_project(self, project) -> bool: + # Public projects are readable by any instance user, including external users. + visibility = getattr(project, "visibility", None) + if visibility == "public": + return True + requester_id = self._get_review_requester_id() + if not requester_id: + return False + if visibility == "internal": + # Internal projects are visible to every signed-in instance user who is not an + # external user; membership is not required. Only a positive "external" verdict + # rejects here; an unknown flag falls through to the membership check and fails + # closed, since external users cannot be granted internal access as members either. + try: + user = self.gl.users.get(requester_id) + except GitlabGetError as e: + # A definitive 404 (no such user) means denial; provider/network failures are + # not a verdict and must surface as a fetch error instead of a silent skip. + if getattr(e, "response_code", None) == 404: + return False + raise + if getattr(user, "external", None) is False: + return True + try: + member = project.members_all.get(requester_id) + except GitlabGetError as e: + # A definitive 404 (not a member) means denial; auth/provider failures are not a + # verdict and must surface as a fetch error instead of silently dropping the file. + if getattr(e, "response_code", None) == 404: + return False + raise + access_level = getattr(member, "access_level", None) + # Reading repository source code requires at least Reporter-level access; Guests, + # minimal-access members, and unknown levels are rejected. Requiring positive membership + # with that level also fails closed for external users, whom GitLab forbids from + # internal projects, and for low-access members of private projects. + return isinstance(access_level, int) and access_level >= _GITLAB_ACCESS_LEVEL_REPORTER + def get_repo_context_ref(self, from_default_branch: bool = False) -> Optional[str]: # The MR target branch (the branch being merged into) is the cached revision; the # project default branch is consulted when from_default_branch is requested or no MR diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index aad72ded91..06478f3a7c 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -140,6 +140,8 @@ async def handle_comments_on_pr(body: Dict[str, Any], log_context["api_url"] = api_url comment_id = body.get("comment", {}).get("id") provider = get_git_provider_with_context(pr_url=api_url) + if isinstance(sender, str) and sender and hasattr(provider, "set_command_actor"): + provider.set_command_actor(sender) with get_logger().contextualize(**log_context): if get_identity_provider().verify_eligibility("github", sender_id, api_url) is not Eligibility.NOT_ELIGIBLE: get_logger().info(f"Processing comment on PR {api_url=}, comment_body={comment_body}") diff --git a/pr_agent/servers/gitlab_webhook.py b/pr_agent/servers/gitlab_webhook.py index dcac8f94ce..fffc7712c8 100644 --- a/pr_agent/servers/gitlab_webhook.py +++ b/pr_agent/servers/gitlab_webhook.py @@ -57,6 +57,14 @@ async def handle_request(api_url: str, body: str, log_context: dict, sender_id: log_context["api_url"] = api_url log_context["app_name"] = get_settings().get("CONFIG.APP_NAME", "Unknown") + # Comment commands can pass arbitrary arguments, so sibling-repo context is authorized + # against the commenter (the command actor) instead of the MR author. Fail closed when no + # trustworthy account can be recorded (e.g. the "unknown" fallback for missing sender data). + if isinstance(sender_id, int) and sender_id: + provider = get_git_provider_with_context(pr_url=api_url) + if hasattr(provider, "set_command_actor"): + provider.set_command_actor(sender_id) + with get_logger().contextualize(**log_context): await PRAgent().handle_request(api_url, body, notify) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index c73ed4f96f..62feb66dd0 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -33,12 +33,14 @@ retry_same_model_on_timeout = true # when false, a timed-out call is not retried skip_keys = [] custom_reasoning_model = false # when true, disables system messages and temperature controls for models that don't support chat-style inputs response_language="en-US" # Language locales code for PR responses in ISO 3166 and ISO 639 format (e.g., "en-US", "it-IT", "zh-CN", ...) -repo_context_files = ["AGENTS.md"] # Repository-relative files (e.g. AGENTS.md, CLAUDE.md) to include as AI prompt context; set to [] to disable +repo_context_files = ["AGENTS.md"] # Repository-relative files (e.g. AGENTS.md, CLAUDE.md) to include as AI prompt context; set to [] to disable local context. A structured entry {"repo_id" = ..., "file_path" = ...} selects a sibling default-branch file from the same namespace/owner; repo_id must be in the host-issued repo_context_sibling_repos allowlist below. Reads use the sibling default branch and share repo_context_max_lines; repository settings may select entries, comment arguments cannot override this key # The repo-context cache is keyed on the revision read: the PR target commit for target-branch # reads, and the resolved default branch head on GitHub, so a default-branch push invalidates # cached content within the TTL. Providers without commit resolution key on a branch name or None. repo_context_from_default_branch = true # Read repo context files from the repository default branch (trusts only default-branch content). Set to false to read from the PR target branch instead. repo_context_max_lines = 500 # Maximum total rendered lines for repo context, including wrapper tags +repo_context_sibling_repos = [] # Host-only list of approved sibling repository identifiers (GitHub owner/repo, GitLab group/project or numeric ID strings) that repo_context_files sibling entries may select. Empty disables sibling reads. Approve only repositories whose content may be disclosed in consuming PRs, because the actor check bounds who triggers a read, not who chose the target or where the output lands. Repository settings and comment arguments cannot change this list. Canonical identities and owning namespaces are checked after resolution +repo_context_max_sibling_files = 5 # Maximum number of sibling-repository files fetched per repo-context build. The fetch count is bounded separately from repo_context_max_lines so selected sibling files cannot trigger an unbounded number of cross-repository calls; sibling files still compete for the repo_context_max_lines budget. Host-only (cannot be raised by a repository's .pr_agent.toml or a comment command) and clamped to a hard ceiling of 20 fetches per build. # token limits max_description_tokens = 500 max_commits_tokens = 500 diff --git a/tests/unittest/test_apply_repo_settings.py b/tests/unittest/test_apply_repo_settings.py index ad8ab37d83..2b7dc50259 100644 --- a/tests/unittest/test_apply_repo_settings.py +++ b/tests/unittest/test_apply_repo_settings.py @@ -92,6 +92,49 @@ def test_repo_without_toml_does_not_inherit_previous_repo_settings( assert "MARKER-FROM-REPO-A" not in _extra_instructions("pr_code_suggestions"), \ "repo A's [pr_code_suggestions].extra_instructions leaked into repo B" + def test_repo_settings_cannot_raise_sibling_context_cap(self, fresh_global_settings, monkeypatch): + """A repo's .pr_agent.toml must not be able to raise + repo_context_max_sibling_files: it bounds cross-repo fetches per build, + so it is host-only. Repo file selection (repo_context_files) may still + be set from the repo, only the cap is refused. + """ + repo_toml = b""" +[config] +repo_context_files = [{repo_id = "group/A/idea", file_path = "AGENTS.md"}] +repo_context_max_sibling_files = 1000 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: FakeGitProvider(repo_toml), + ) + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1") + assert get_settings().config.get("repo_context_max_sibling_files") == 5, \ + "Repo settings must not be able to raise repo_context_max_sibling_files" + assert get_settings().config.get("repo_context_files") == [ + {"repo_id": "group/A/idea", "file_path": "AGENTS.md"}, + ], "Repo settings should still be able to select repo_context_files" + + def test_repo_selects_siblings_without_changing_host_allowlist(self, fresh_global_settings, monkeypatch): + repo_toml = b""" +[config] +repo_context_sibling_repos = ["group/private"] +repo_context_files = [{repo_id = "group/approved", file_path = "api.py"}] +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: FakeGitProvider(repo_toml), + ) + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + get_settings().set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/approved"]) + git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1") + assert get_settings().config.repo_context_sibling_repos == ["group/approved"] + assert get_settings().config.repo_context_files == [ + {"repo_id": "group/approved", "file_path": "api.py"}, + ] + def test_unknown_section_does_not_leak_to_next_repo(self, fresh_global_settings, monkeypatch): """Catches the case where a repo's `.pr_agent.toml` introduces a section name not present in the startup defaults. With the per-request clone, diff --git a/tests/unittest/test_cli_args_security.py b/tests/unittest/test_cli_args_security.py index 1f775d4bd9..77bf752102 100644 --- a/tests/unittest/test_cli_args_security.py +++ b/tests/unittest/test_cli_args_security.py @@ -68,6 +68,21 @@ "--pr_reviewer.publish_error_details=true", "--pr_reviewer__publish_error_details=true", '--pr_reviewer={"publish_error_details": true}', + # repo_context_max_sibling_files is host-only: letting a comment raise it would defeat the + # sibling-fetch safety bound and allow unbounded cross-repository API calls. + "--config.repo_context_max_sibling_files=1000", + "--config__repo_context_max_sibling_files=1000", + '--config={"repo_context_max_sibling_files": 1000}', + # repo_context_files selects which files become model instructions, so comment arguments + # must not be able to point the bot at arbitrary sibling repo content. + '--config.repo_context_files=[{"repo_id": "group/A/idea", "file_path": "AGENTS.md"}]', + "--config__repo_context_files=[\"AGENTS.md\"]", + '--config={"repo_context_files": ["AGENTS.md"]}', + # repo_context_sibling_repos is the host-only allowlist of sibling repositories whose + # files may be selected; neither repo settings nor comment arguments can change it. + "--config.repo_context_sibling_repos=[]", + "--config__repo_context_sibling_repos=[]", + '--config={"repo_context_sibling_repos": []}', ] diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index d829e9a05c..3d625afff3 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 260 + assert sum(keys.values()) == 262 assert { "model", diff --git a/tests/unittest/test_repo_context.py b/tests/unittest/test_repo_context.py index 6446df5f3f..aac196e5d1 100644 --- a/tests/unittest/test_repo_context.py +++ b/tests/unittest/test_repo_context.py @@ -3,6 +3,7 @@ import pytest from github import GithubException +from gitlab.exceptions import GitlabGetError from jinja2 import Environment, StrictUndefined, select_autoescape from pr_agent.algo import repo_context @@ -48,20 +49,38 @@ class UnsupportedProvider: get_repo_file_content = GitProvider.get_repo_file_content +class SiblingFakeProvider(FakeProvider): + """FakeProvider that additionally resolves sibling-repository files.""" + + def __init__(self, files, sibling_files=None, pr_url=None): + super().__init__(files, pr_url) + self.sibling_files = sibling_files or {} + self.requested_siblings = [] + + def get_sibling_repo_file_content(self, repo_id: str, file_path: str, from_default_branch: bool = False): + self.requested_siblings.append(f"{repo_id}:{file_path}") + return self.sibling_files.get(f"{repo_id}:{file_path}") + + @pytest.fixture def repo_context_settings(): settings = get_settings() original_files = settings.config.get("repo_context_files", []) original_max_lines = settings.config.get("repo_context_max_lines", 500) original_from_default_branch = settings.config.get("repo_context_from_default_branch", True) + original_sibling_repos = settings.config.get("repo_context_sibling_repos", []) + settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", []) + original_max_sibling_files = settings.config.get("repo_context_max_sibling_files", 5) original_warned_provider_classes = repo_context._unsupported_repo_context_provider_classes.copy() original_process_cache = repo_context._repo_context_process_cache.copy() yield settings + settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", original_sibling_repos) settings.set("CONFIG.REPO_CONTEXT_FILES", original_files) settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", original_max_lines) settings.set("CONFIG.REPO_CONTEXT_FROM_DEFAULT_BRANCH", original_from_default_branch) + settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", original_max_sibling_files) repo_context._unsupported_repo_context_provider_classes = original_warned_provider_classes repo_context._repo_context_process_cache = original_process_cache @@ -301,7 +320,7 @@ def test_load_repo_context_files_normalizes_fetch_results(): provider, ["AGENTS.md", "EMPTY.md", "MISSING.md", " "] ) - assert files == {"AGENTS.md": "Repo purpose"} + assert files == [("AGENTS.md", "Repo purpose")] assert had_fetch_error is False assert provider.requested_paths == ["AGENTS.md", "EMPTY.md", "MISSING.md"] @@ -312,10 +331,937 @@ def test_load_repo_context_files_reports_fetch_errors(): files, had_fetch_error = repo_context._load_repo_context_files(provider, ["AGENTS.md"]) - assert files == {} + assert files == [] assert had_fetch_error is True +@pytest.mark.parametrize( + "entry,expected_repo_id,expected_path", + [ + ("AGENTS.md", None, "AGENTS.md"), + ("docs/guide.md", None, "docs/guide.md"), + (" src/ x.py ", None, "src/ x.py"), + ("docs/guide:part.md", None, "docs/guide:part.md"), + ({"repo_id": "group/sub/lib", "file_path": "src/api.py"}, "group/sub/lib", "src/api.py"), + ({"repo_id": "owner/lib", "file_path": "/deep/file.py"}, "owner/lib", "deep/file.py"), + ({"repo_id": "owner/lib", "file_path": " file.py "}, "owner/lib", "file.py"), + ({"repo_id": " owner/lib ", "file_path": " file.py "}, "owner/lib", "file.py"), + ], +) +def test_parse_repo_context_file_entry(entry, expected_repo_id, expected_path): + assert repo_context._parse_repo_context_file_entry(entry) == (expected_repo_id, expected_path) + + +@pytest.mark.parametrize( + "entry", + [ + {"repo_id": "group/lib", "file_path": ""}, + {"repo_id": "", "file_path": "src/api.py"}, + {"repo_id": "group/lib"}, + {"file_path": "src/api.py"}, + {"repo_id": 123, "file_path": "src/api.py"}, + {"repo_id": "group/lib", "file_path": "/"}, + 123, + ], +) +def test_parse_repo_context_file_entry_rejects_malformed(entry): + # The parser is pure: malformed entries read as invalid, and the loader reports the skip. + assert repo_context._parse_repo_context_file_entry(entry) == (None, "") + + +def test_parse_repo_context_file_entry_treats_blank_as_plain_path(): + assert repo_context._parse_repo_context_file_entry(" ") == (None, "") + assert repo_context._parse_repo_context_file_entry("") == (None, "") + + +def test_load_repo_context_files_fetches_sibling_and_same_repo_files(): + provider = SiblingFakeProvider( + files={"AGENTS.md": "Repo purpose"}, + sibling_files={"group/lib-api:src/interfaces/api.py": "Sibling contract"}, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + ["AGENTS.md", {"repo_id": "group/lib-api", "file_path": "src/interfaces/api.py"}], + from_default_branch=True, + ) + + # The sibling file is rendered under its sibling path so the model sees where it came from. + assert files == [ + ("AGENTS.md", "Repo purpose"), + ("group/lib-api/src/interfaces/api.py", "Sibling contract"), + ] + assert had_fetch_error is False + assert provider.requested_paths == ["AGENTS.md"] + assert provider.requested_siblings == ["group/lib-api:src/interfaces/api.py"] + + +def test_load_repo_context_files_treats_colon_string_as_local_path(): + provider = SiblingFakeProvider( + files={"docs/guide:part.md": "Local content"}, + sibling_files={"docs/guide:part.md": "Sibling content"}, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, ["docs/guide:part.md"], from_default_branch=True + ) + + # A ':' inside a plain string is a local path, never a sibling reference. + assert files == [("docs/guide:part.md", "Local content")] + assert had_fetch_error is False + assert provider.requested_paths == ["docs/guide:part.md"] + assert provider.requested_siblings == [] + + +def test_load_repo_context_files_reports_sibling_fetch_errors(): + provider = SiblingFakeProvider(files={}) + provider.get_sibling_repo_file_content = Mock(side_effect=Exception("temporary outage")) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, [{"repo_id": "group/lib-api", "file_path": "src/api.py"}], from_default_branch=True + ) + + assert files == [] + assert had_fetch_error is True + + +def test_load_repo_context_files_skips_siblings_for_unsupported_provider(): + provider = FakeProvider({"AGENTS.md": "Repo purpose"}) + provider.get_sibling_repo_file_content = Mock() + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, ["AGENTS.md", {"repo_id": "group/lib-api", "file_path": "src/api.py"}], from_default_branch=True + ) + + assert files == [("AGENTS.md", "Repo purpose")] + assert had_fetch_error is False + assert provider.requested_paths == ["AGENTS.md"] + provider.get_sibling_repo_file_content.assert_not_called() + + +def test_load_repo_context_files_respects_sibling_file_cap(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider( + files={}, + sibling_files={ + "group/g1:README.md": "one", + "group/g2:README.md": "two", + "group/g3:README.md": "three", + }, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + {"repo_id": "group/g3", "file_path": "README.md"}, + {"repo_id": "group/g4", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + assert files == [ + ("group/g1/README.md", "one"), + ("group/g2/README.md", "two"), + ] + assert had_fetch_error is False + assert provider.requested_siblings == [ + "group/g1:README.md", + "group/g2:README.md", + ] + + +def test_sibling_fetch_cap_counts_unique_pairs_not_duplicate_entries(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider( + files={}, + sibling_files={ + "group/g1:README.md": "one", + "group/g2:README.md": "two", + }, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + # A duplicate entry must not consume the fetch cap: each unique pair is fetched once. + assert files == [ + ("group/g1/README.md", "one"), + ("group/g2/README.md", "two"), + ] + assert had_fetch_error is False + assert provider.requested_siblings == [ + "group/g1:README.md", + "group/g2:README.md", + ] + + +def test_read_max_sibling_context_files_clamps_oversized_value(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 10**6) + assert repo_context._read_max_sibling_context_files() == repo_context._HARD_MAX_SIBLING_CONTEXT_FILES + + +def test_read_max_sibling_context_files_defaults_on_bad_value(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", "not-a-number") + assert repo_context._read_max_sibling_context_files() == 5 + + +def test_read_max_sibling_context_files_floor_at_zero(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", -100) + assert repo_context._read_max_sibling_context_files() == 0 + + +def test_load_repo_context_files_clamps_sibling_fetches_to_hard_ceiling(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 10**6) + hard_max = repo_context._HARD_MAX_SIBLING_CONTEXT_FILES + context_files = [{"repo_id": f"group/g{i}", "file_path": "README.md"} for i in range(hard_max + 5)] + provider = SiblingFakeProvider( + files={}, + sibling_files={f"group/g{i}:README.md": str(i) for i in range(hard_max + 5)}, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, context_files, from_default_branch=True + ) + + assert had_fetch_error is False + assert provider.requested_siblings == [f"group/g{i}:README.md" for i in range(hard_max)] + + +def test_sibling_fetch_cap_counts_attempts_not_just_content(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider( + files={}, + sibling_files={ + "group/g1:README.md": "", + "group/g2:README.md": "two", + }, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + {"repo_id": "group/g3", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + assert files == [("group/g2/README.md", "two")] + assert provider.requested_siblings == [ + "group/g1:README.md", + "group/g2:README.md", + ] + + +def test_sibling_fetch_cap_counts_exceptions(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider(files={}) + call_count = 0 + original = provider.get_sibling_repo_file_content + + def counting_call(repo_id, file_path): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("transient outage") + return original(repo_id, file_path) + + provider.get_sibling_repo_file_content = Mock(side_effect=counting_call) + provider.requested_siblings = [] + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + {"repo_id": "group/g3", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + assert call_count == 2 + assert had_fetch_error is True + + +def test_build_repo_context_renders_sibling_file_with_budget(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/lib-api"]) + repo_context_settings.set( + "CONFIG.REPO_CONTEXT_FILES", + [{"repo_id": "group/lib-api", "file_path": "src/interfaces/api.py"}] + ) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + provider = SiblingFakeProvider( + files={}, + sibling_files={"group/lib-api:src/interfaces/api.py": "def call(req): ...\n"}, + ) + + context = build_repo_context(provider) + + assert context == ( + "You are being given instruction files. Follow them as project-specific guidance when reviewing code.\n" + "\n" + '\n' + "`````markdown\n" + "def call(req): ...\n" + "`````\n" + "\n\n" + "" + ) + + +def test_build_repo_context_renders_local_and_sibling_with_same_label(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/lib"]) + repo_context_settings.set( + "CONFIG.REPO_CONTEXT_FILES", + [ + "group/lib/README.md", + {"repo_id": "group/lib", "file_path": "README.md"}, + ], + ) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + provider = SiblingFakeProvider( + files={"group/lib/README.md": "Local README\n"}, + sibling_files={"group/lib:README.md": "Sibling README\n"}, + ) + + context = build_repo_context(provider) + + # A local path that equals a sibling's rendered label must not overwrite the other: both + # configured instruction files reach the prompt, distinguished by their source content. + assert context == ( + "You are being given instruction files. Follow them as project-specific guidance when reviewing code.\n" + "\n" + '\n' + "`````markdown\n" + "Local README\n" + "`````\n" + "\n\n" + '\n' + "`````markdown\n" + "Sibling README\n" + "`````\n" + "\n\n" + "" + ) + + +@pytest.fixture +def sibling_provider_settings(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["myorg/lib", "group/sub/lib", "456"]) + return repo_context_settings + + +def test_github_provider_fetches_sibling_file_in_same_owner(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + provider.github_client.get_repo.assert_called_once_with("myorg/lib") + sibling_repo.get_contents.assert_called_once_with("src/api.py") + + +def test_github_provider_fetches_private_sibling_when_requester_is_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.has_in_collaborators.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + + +def test_github_provider_fetches_private_sibling_when_requester_is_owner(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.owner = SimpleNamespace(login="alice") + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_not_called() + + +def test_github_provider_rejects_private_sibling_when_requester_is_not_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + sibling_repo.get_contents.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: myorg/lib" + ) + + +def test_github_provider_rejects_private_sibling_when_requester_is_unknown(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + sibling_repo.get_contents.assert_not_called() + + +def test_github_provider_rejects_private_sibling_when_command_actor_lacks_access(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + provider.set_command_actor("mallory") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.visibility = "private" + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + # The commenter (command actor), not the PR author, is the identity that must be verified. + sibling_repo.has_in_collaborators.assert_called_once_with("mallory") + sibling_repo.get_contents.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: myorg/lib" + ) + + +def test_github_provider_fetches_private_sibling_when_command_actor_is_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + provider.set_command_actor("mallory") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.visibility = "private" + sibling_repo.has_in_collaborators.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_called_once_with("mallory") + + +def test_github_provider_rejects_internal_sibling_when_requester_is_not_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = None # not an organization-controlled repository + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + sibling_repo.get_contents.assert_not_called() + + +def test_github_provider_fetches_internal_sibling_when_command_actor_is_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = None + sibling_repo.has_in_collaborators.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + + +def test_github_provider_fetches_internal_sibling_when_requester_is_org_member(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = Mock() + sibling_repo.organization.has_in_members.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + # Internal repositories are readable by every member of the owning organization without a + # per-repo collaborator grant, so the membership check is both sufficient and necessary. + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + provider.github_client.get_user.assert_called_once_with("alice") + sibling_repo.organization.has_in_members.assert_called_once_with(provider.github_client.get_user.return_value) + sibling_repo.has_in_collaborators.assert_not_called() + + +def test_github_provider_rejects_internal_sibling_to_non_member_non_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = Mock() + sibling_repo.organization.has_in_members.return_value = False + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + # Not an org member; outside collaborators are the remaining way into an internal repo. + provider.github_client.get_user.assert_called_once_with("alice") + sibling_repo.organization.has_in_members.assert_called_once_with(provider.github_client.get_user.return_value) + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + sibling_repo.get_contents.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: myorg/lib" + ) + + +def test_github_provider_fetches_sibling_when_owner_case_differs(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "MyOrg/current" + provider.get_owning_namespace = Mock(return_value="MyOrg") + provider.github_client = Mock() + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + # Owner logins are case-insensitive on GitHub, so differently-cased siblings are valid. + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + provider.github_client.get_repo.assert_called_once_with("myorg/lib") + sibling_repo.get_contents.assert_called_once_with("src/api.py") + + +def test_github_provider_propagates_sibling_collaborator_errors(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.has_in_collaborators.side_effect = GithubException(500, {"message": "boom"}, {}) + provider.github_client.get_repo.return_value = sibling_repo + + # A transient provider failure must surface as a fetch error, not read as a denial. + with pytest.raises(GithubException): + provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") + sibling_repo.get_contents.assert_not_called() + + # A plain denial stays returnable: a definitive 404 means the requester is not a collaborator. + sibling_repo.has_in_collaborators.side_effect = GithubException(404, {"message": "Not Found"}, {}) + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + +def test_github_provider_propagates_org_membership_errors(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = Mock() + sibling_repo.organization.has_in_members.side_effect = GithubException(500, {"message": "boom"}, {}) + provider.github_client.get_repo.return_value = sibling_repo + + with pytest.raises(GithubException): + provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") + sibling_repo.has_in_collaborators.assert_not_called() + sibling_repo.get_contents.assert_not_called() + + # A 404 (organization cannot be resolved) is not a membership verdict: fall through. + sibling_repo.organization.has_in_members.side_effect = GithubException(404, {"message": "Not Found"}, {}) + sibling_repo.has_in_collaborators.return_value = False + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + provider.github_client.get_user.assert_called_with("alice") + sibling_repo.organization.has_in_members.assert_called_with(provider.github_client.get_user.return_value) + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + + +def test_github_provider_rejects_out_of_owner_sibling_without_api_call(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("other-org/lib", "src/api.py") == "" + + provider.github_client.get_repo.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo absent from the host allowlist: other-org/lib" + ) + + +def test_github_provider_treats_missing_sibling_file_as_no_context(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.github_client.get_repo.side_effect = GithubException(404, {"message": "Not Found"}, {}) + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + +def test_gitlab_provider_fetches_sibling_file_in_same_namespace(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "public" + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + provider.gl.projects.get.assert_called_once_with("group/sub/lib") + sibling_project.files.get.assert_called_once_with(file_path="src/api.py", ref="main") + + +def test_gitlab_provider_rejects_internal_sibling_when_requester_is_not_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # Internal projects forbid external users and low-role/unknown members, so membership + # must be positively established before the sibling is read. + sibling_project.members_all.get.assert_called_once_with(42) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_fetches_internal_sibling_when_requester_is_read_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=30) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + sibling_project.members_all.get.assert_called_once_with(42) + + +def test_gitlab_provider_fetches_internal_sibling_for_non_external_instance_user(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + # The requester is a signed-in instance user who is not an external user. + provider.gl.users.get.return_value = SimpleNamespace(external=False) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + # Internal projects are visible to every non-external instance user without membership. + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + provider.gl.users.get.assert_called_once_with(42) + sibling_project.members_all.get.assert_not_called() + sibling_project.files.get.assert_called_once_with(file_path="src/api.py", ref="main") + + +def test_gitlab_provider_rejects_internal_sibling_for_external_user_non_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.gl.users.get.return_value = SimpleNamespace(external=True) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # External users cannot be granted internal access as members either, so this fails closed. + provider.gl.users.get.assert_called_once_with(42) + sibling_project.members_all.get.assert_called_once_with(42) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_propagates_membership_provider_errors(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.side_effect = GitlabGetError("boom", response_code=500) + provider.gl.projects.get.return_value = sibling_project + + # A transient membership provider failure must surface as a fetch error, not a denial. + with pytest.raises(GitlabGetError): + provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") + sibling_project.files.get.assert_not_called() + + +def test_gitlab_provider_propagates_user_lookup_provider_errors(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.gl.users.get.side_effect = GitlabGetError("boom", response_code=500) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + provider.gl.projects.get.return_value = sibling_project + + with pytest.raises(GitlabGetError): + provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") + sibling_project.members_all.get.assert_not_called() + sibling_project.files.get.assert_not_called() + + # A definitive 404 (no such user) is a plain denial: the requester cannot be a member. + provider.gl.users.get.side_effect = GitlabGetError("Not found", response_code=404) + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + sibling_project.members_all.get.assert_not_called() + + +def test_gitlab_provider_rejects_private_sibling_when_member_lacks_repo_read(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=10) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # Guests and minimal-access members cannot read private repository source files. + sibling_project.members_all.get.assert_called_once_with(42) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_fetches_private_sibling_when_requester_is_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=30) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + sibling_project.members_all.get.assert_called_once_with(42) + + +def test_gitlab_provider_rejects_private_sibling_when_command_actor_is_not_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.set_command_actor(99) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # The commenter (command actor), not the MR author, is the identity that must be verified. + sibling_project.members_all.get.assert_called_once_with(99) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_fetches_private_sibling_when_command_actor_is_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.set_command_actor(99) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=30) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + sibling_project.members_all.get.assert_called_once_with(99) + + +def test_gitlab_provider_rejects_private_sibling_when_requester_is_not_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_rejects_private_sibling_when_requester_is_unknown(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + sibling_project.files.get.assert_not_called() + sibling_project.members_all.get.assert_not_called() + + +def test_gitlab_provider_rejects_out_of_namespace_sibling_without_api_call(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("other/lib", "src/api.py") == "" + + provider.gl.projects.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo absent from the host allowlist: other/lib" + ) + + def test_build_repo_context_process_cache_invalidates_when_config_changes(repo_context_settings): repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", ["AGENTS.md"]) repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) @@ -349,7 +1295,130 @@ def test_build_repo_context_does_not_cache_empty_context_after_fetch_error(repo_ assert first_context == "" assert "Repo purpose" in second_context - assert provider.get_repo_file_content.call_count == 2 + + +def test_build_repo_context_does_not_cache_sibling_content(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/lib"]) + repo_context_settings.set( + "CONFIG.REPO_CONTEXT_FILES", + [{"repo_id": "group/lib", "file_path": "api.py"}] + ) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + provider = SiblingFakeProvider( + files={}, + sibling_files={"group/lib:api.py": "def call(req): ...\n"}, + pr_url="https://example.com/org/repo/pull/1", + ) + + first_context = build_repo_context(provider) + assert " Date: Tue, 15 Sep 2026 14:51:25 +0800 Subject: [PATCH 105/238] fix: bound and redact the MOSAICO health probe (#3378) --- docker/mosaico/README.md | 18 ++- docker/mosaico/smoke_test.sh | 8 +- docs/docs/installation/mosaico_server.md | 10 ++ .../usage-guide/configuration_reference.md | 7 + pr_agent/mosaico/executor.py | 16 +- pr_agent/settings/configuration.toml | 3 + tests/unittest/test_config_reference.py | 2 +- tests/unittest/test_mosaico_health.py | 147 +++++++++++++++++- 8 files changed, 196 insertions(+), 15 deletions(-) diff --git a/docker/mosaico/README.md b/docker/mosaico/README.md index 47234847d2..45a677136b 100644 --- a/docker/mosaico/README.md +++ b/docker/mosaico/README.md @@ -132,9 +132,21 @@ Two outcomes: ## Troubleshooting - **Container stays `unhealthy`, registration never runs.** `/health` is a live LLM probe and - returns `503` on bad/missing credentials — this is intended (the healthcheck matches the - peer solution agents' probe verbatim, and a registered card backed by a dead LLM is worse - than no registration). Check `API_BASE`/`API_KEY`/`MODEL_NAME`, not the compose file. + returns `503` on bad/missing credentials — this is intended: a registered card backed by + a dead LLM is worse than no registration. Images with health-probe hardening return + `Unhealthy: LLM probe failed` for provider failures. The health check's own warning logs only the exception type, + not the provider's error text. + `mosaico.health_timeout_seconds` sets the deadline for cooperative asynchronous work + (default: 10 seconds; must be a finite positive number). Synchronous + handler construction and blocking SDK work can still exceed it. Existing image tags may + predate this behavior and expose provider error details. + Set `MOSAICO__HEALTH_TIMEOUT_SECONDS` in the agent container's environment to override + the default; with Compose, add it to the service's `environment` mapping. + Invalid values make `/health` return the generic unhealthy response (503), not the default timeout. + The bundled Compose probe has its own 25-second HTTP timeout. For longer health budgets, + increase that HTTP timeout and Docker's `healthcheck.timeout` with sufficient margin; + otherwise the container can remain unhealthy and registration will not run. + Check `API_BASE`/`API_KEY`/`MODEL_NAME`, not the compose file. - **Agent registers but the reference agent never reaches it.** The advertised card URL is `localhost`; see the `AGENT_CARD_HOST`/`AGENT_CARD_PORT` section above. - **The registration container itself can't fetch the agent card.** `01-compose.sh` falls back diff --git a/docker/mosaico/smoke_test.sh b/docker/mosaico/smoke_test.sh index fa1f49b667..1aa1c88f85 100755 --- a/docker/mosaico/smoke_test.sh +++ b/docker/mosaico/smoke_test.sh @@ -23,8 +23,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}" BASE="http://localhost:${PORT}" -# 0700 by construction, so the /health body (which embeds the raw provider exception -# when unhealthy) is neither world-readable nor writable at a predictable path. +# 0700 by construction, so captured responses are neither world-readable nor +# writable at a predictable path, including when testing older image versions. TMPDIR_RUN="$(mktemp -d)" || { echo "FAIL: mktemp -d failed" >&2; exit 1; } # Only tear down a container this run actually started: the name is fixed, so an early # exit (a failed pull, or a `docker run` that lost a name race) must not reap someone @@ -106,8 +106,8 @@ fi # --- FULL: /health (live LLM ping -> 200/503) --- echo "==> [full] GET /health (live LLM probe)" code=$(curl -s "${CURL_CONNECT[@]}" --max-time 120 -o "$TMPDIR_RUN/health.json" -w '%{http_code}' "$BASE/health") -# On 503 the body is a raw provider exception (it can name the endpoint), which is -# exactly the diagnostic you want here - just don't paste it into a public issue. +# Images without health-probe redaction can include provider details in a 503 body; +# inspect captured output before sharing it publicly. cat "$TMPDIR_RUN/health.json"; echo [[ "$code" == "200" ]] || fail "/health returned $code (expected 200) — check LLM creds" diff --git a/docs/docs/installation/mosaico_server.md b/docs/docs/installation/mosaico_server.md index f4e3b04275..87b6667950 100644 --- a/docs/docs/installation/mosaico_server.md +++ b/docs/docs/installation/mosaico_server.md @@ -18,6 +18,16 @@ The A2A server exposes three endpoints: | `/` | POST | A2A 1.0 JSON-RPC. Sends the `SendMessage` method with an `A2A-Version: 1.0` header (the header is required: the server treats a request without it as protocol 0.3 and rejects it). The reply arrives as a task artifact (`result.task.artifacts[].parts[].text`), not as a status message. | | `/health` | GET | A **live LLM connectivity probe** — `200` when an LLM round-trip succeeds, `503` otherwise | +Images with health-probe hardening return `Unhealthy: LLM probe failed` for provider +failures; the health check's own warning records only the exception type. +`mosaico.health_timeout_seconds` sets a finite positive deadline in seconds (default: 10) +for cooperative asynchronous preparation and dispatch. Synchronous initialization and +blocking SDK work can exceed this deadline. Older images may predate these protections. +Set `MOSAICO__HEALTH_TIMEOUT_SECONDS` in the server's environment to override the default. +Invalid values produce the generic unhealthy response (503), rather than using the default. +When increasing the budget, also allow sufficient time in any external health-check client +and container healthcheck; the bundled Compose probe uses a separate 25-second HTTP timeout. + The advertised agent card carries the skills `review`, `improve`, `describe`, and `ask`, the name `"PR-Agent Solution Agent"`, a `version` derived from the running build (never hand-maintained), and the required diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 17127665f4..a0c91307df 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -511,6 +511,13 @@ _This section only documents commented-out examples; see the [TOML source](https | `max_artifact_size` | 50000 | Max artifact size in characters (content is truncated if exceeded) | +## `[mosaico]` + +| Key | Default | Description | +| --- | --- | --- | +| `health_timeout_seconds` | 10 | finite positive seconds for cooperative health-probe work; excludes synchronous initialization and blocking SDK work | + + ## `[asana]` | Key | Default | Description | diff --git a/pr_agent/mosaico/executor.py b/pr_agent/mosaico/executor.py index 42183e280e..0c586f7bbc 100644 --- a/pr_agent/mosaico/executor.py +++ b/pr_agent/mosaico/executor.py @@ -18,7 +18,9 @@ "Agent should enqueue Task before TaskStatusUpdateEvent event". health_check issues a single, NON-retry-wrapped litellm probe.""" +import asyncio import copy +from math import isfinite from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue @@ -120,8 +122,16 @@ async def health_check() -> str: model = get_settings().get("CONFIG.MODEL", None) if not model: return "Unhealthy: no model configured" - await handler.probe_completion(model, _completion=litellm.acompletion) + timeout = get_settings().get("MOSAICO.HEALTH_TIMEOUT_SECONDS", 10) + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not isfinite(timeout) or timeout <= 0: + raise ValueError("MOSAICO health timeout must be a finite positive number") + # Bound cooperative waits, including request preparation, not just HTTP I/O. + # Synchronous handler construction and blocking SDK work cannot be interrupted. + async with asyncio.timeout(timeout): + await handler.probe_completion( + model, timeout=timeout, _completion=litellm.acompletion, + ) return "OK" except Exception as e: - get_logger().warning(f"MOSAICO health_check unhealthy: {e}") - return f"Unhealthy: {e}" + get_logger().warning(f"MOSAICO health_check unhealthy: {type(e).__name__}") + return "Unhealthy: LLM probe failed" diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 62feb66dd0..72a94ff191 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -515,6 +515,9 @@ target_tools = ["pr_reviewer", "pr_description", "pr_code_suggestions"] # Max artifact size in characters (content is truncated if exceeded) max_artifact_size = 50000 +[mosaico] +health_timeout_seconds = 10 # finite positive seconds for cooperative health-probe work; excludes synchronous initialization and blocking SDK work + [asana] request_timeout = 10 # seconds allowed for each Asana task API request diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index 3d625afff3..142d331e22 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 262 + assert sum(keys.values()) == 263 assert { "model", diff --git a/tests/unittest/test_mosaico_health.py b/tests/unittest/test_mosaico_health.py index a379b24956..099f07164b 100644 --- a/tests/unittest/test_mosaico_health.py +++ b/tests/unittest/test_mosaico_health.py @@ -7,12 +7,19 @@ Also exercises the REAL health_check() (no stub) to lock in Fix A: the removed 'stop'-param gate must NOT short-circuit /health for models that lack 'stop', since PR-Agent's LiteLLMAIHandler never sends 'stop'.""" +import asyncio +from contextlib import suppress +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, call + import httpx import litellm import pytest -import pr_agent.mosaico.server as server_mod +from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.config_loader import get_settings +from pr_agent.mosaico import executor as executor_mod +from pr_agent.mosaico import server as server_mod from pr_agent.mosaico.executor import health_check from pr_agent.mosaico.server import build_app from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -63,7 +70,7 @@ async def test_unhealthy_returns_503(self, monkeypatch): def restore_config_model(): """Restore LLM settings exactly, including originally-absent state.""" snapshot = snapshot_settings( - ["CONFIG.MODEL", "LITELLM.CUSTOM_LLM_PROVIDER", "OPENAI.KEY", "GROQ.KEY"] + ["CONFIG.MODEL", "LITELLM.CUSTOM_LLM_PROVIDER", "OPENAI.KEY", "GROQ.KEY", "MOSAICO.HEALTH_TIMEOUT_SECONDS"] ) yield get_settings() restore_settings(snapshot) @@ -125,8 +132,140 @@ async def boom_acompletion(**kwargs): monkeypatch.setattr(litellm, "acompletion", boom_acompletion) result = await health_check() - assert result.startswith("Unhealthy:") - assert "connection refused" in result + assert result == "Unhealthy: LLM probe failed" + + @pytest.mark.asyncio + @pytest.mark.parametrize("failure_stage", ["construction", "completion"]) + async def test_failure_details_do_not_reach_http_body_or_health_log( + self, monkeypatch, restore_config_model, failure_stage + ): + restore_config_model.set("CONFIG.MODEL", _MODEL_WITHOUT_STOP) + error = RuntimeError("private-provider.example: credential=health-test-secret") + logger = MagicMock() + monkeypatch.setattr(executor_mod, "get_logger", lambda: logger) + completion = AsyncMock(side_effect=error) + monkeypatch.setattr(litellm, "acompletion", completion) + if failure_stage == "construction": + monkeypatch.setattr(LiteLLMAIHandler, "__init__", MagicMock(side_effect=error)) + + response = await _get_health(build_app()) + + assert response.status_code == 503 + assert response.json() == { + "is_healthy": False, + "status": "Unhealthy: LLM probe failed", + "detail": "Unhealthy: LLM probe failed", + } + assert logger.mock_calls == [call.warning("MOSAICO health_check unhealthy: RuntimeError")] + assert completion.await_count == (failure_stage == "completion") + + @pytest.mark.asyncio + @pytest.mark.parametrize("wait_stage", ["preparation", "completion"]) + async def test_deadline_cancels_cooperative_probe_work( + self, monkeypatch, restore_config_model, wait_stage + ): + restore_config_model.set("CONFIG.MODEL", _MODEL_WITHOUT_STOP) + deadline = asyncio.timeout(None) + seen_timeouts = [] + + def controlled_timeout(seconds): + seen_timeouts.append(seconds) + return deadline + + monkeypatch.setattr(executor_mod, "asyncio", SimpleNamespace(timeout=controlled_timeout)) + logger = MagicMock() + monkeypatch.setattr(executor_mod, "get_logger", lambda: logger) + cancelled = asyncio.Event() + + async def wait_forever(*args, **kwargs): + # Expire only after reaching the intended stage, without a timing race. + deadline.reschedule(asyncio.get_running_loop().time()) + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + completion = AsyncMock(side_effect=wait_forever) + monkeypatch.setattr(litellm, "acompletion", completion) + if wait_stage == "preparation": + monkeypatch.setattr(LiteLLMAIHandler, "_get_provider_request_params_async", wait_forever) + + result = await asyncio.wait_for(health_check(), timeout=1) + + assert seen_timeouts == [restore_config_model.get("MOSAICO.HEALTH_TIMEOUT_SECONDS")] + assert result == "Unhealthy: LLM probe failed" + assert cancelled.is_set() + logger.warning.assert_called_once_with("MOSAICO health_check unhealthy: TimeoutError") + assert completion.await_count == (wait_stage == "completion") + if wait_stage == "completion": + assert completion.call_args.kwargs["timeout"] == seen_timeouts[0] + + @pytest.mark.asyncio + @pytest.mark.parametrize("timeout", [10, 25, 0.5]) + async def test_configured_timeout_applies_to_preparation_and_dispatch( + self, monkeypatch, restore_config_model, timeout + ): + restore_config_model.set("CONFIG.MODEL", _MODEL_WITHOUT_STOP) + restore_config_model.set("MOSAICO.HEALTH_TIMEOUT_SECONDS", timeout) + seen_timeouts = [] + + def controlled_timeout(seconds): + seen_timeouts.append(seconds) + return asyncio.timeout(seconds) + + monkeypatch.setattr(executor_mod, "asyncio", SimpleNamespace(timeout=controlled_timeout)) + completion = AsyncMock() + monkeypatch.setattr(litellm, "acompletion", completion) + + assert await health_check() == "OK" + assert seen_timeouts == [timeout] + completion.assert_awaited_once() + assert completion.call_args.kwargs["timeout"] == timeout + + @pytest.mark.asyncio + @pytest.mark.parametrize("timeout", [None, True, False, 0, -1, float("nan"), float("inf"), "secret-timeout", []]) + async def test_invalid_timeout_fails_without_dispatch( + self, monkeypatch, restore_config_model, timeout + ): + restore_config_model.set("CONFIG.MODEL", _MODEL_WITHOUT_STOP) + restore_config_model.set("MOSAICO.HEALTH_TIMEOUT_SECONDS", timeout) + logger = MagicMock() + monkeypatch.setattr(executor_mod, "get_logger", lambda: logger) + completion = AsyncMock() + monkeypatch.setattr(litellm, "acompletion", completion) + + assert await health_check() == "Unhealthy: LLM probe failed" + completion.assert_not_awaited() + assert logger.mock_calls == [call.warning("MOSAICO health_check unhealthy: ValueError")] + + @pytest.mark.asyncio + async def test_caller_cancellation_propagates_without_retry_or_failure_log( + self, monkeypatch, restore_config_model + ): + restore_config_model.set("CONFIG.MODEL", _MODEL_WITHOUT_STOP) + logger = MagicMock() + monkeypatch.setattr(executor_mod, "get_logger", lambda: logger) + started = asyncio.Event() + + async def wait_forever(**kwargs): + started.set() + await asyncio.Event().wait() + + completion = AsyncMock(side_effect=wait_forever) + monkeypatch.setattr(litellm, "acompletion", completion) + task = asyncio.create_task(health_check()) + try: + await asyncio.wait_for(started.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + completion.assert_awaited_once() + logger.warning.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize( From fb8a7fddf6ec6ca3d567692da94b879510e35cf7 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:36:31 +0545 Subject: [PATCH 106/238] fix: validate review model responses (#3372) --- pr_agent/algo/utils.py | 5 ++ pr_agent/tools/pr_reviewer.py | 50 +++++++++++++- tests/unittest/test_convert_to_markdown.py | 30 ++++++++- tests/unittest/test_pr_reviewer_core.py | 65 +++++++++++++++++++ .../test_review_large_diff_chunking.py | 31 +++++++-- 5 files changed, 175 insertions(+), 6 deletions(-) diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 44b7d9b359..abf89703ac 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -482,6 +482,11 @@ def convert_to_markdown_v2(output_data: dict, try: if not issue or not isinstance(issue, dict): continue + if any( + field in issue and not isinstance(issue[field], str) + for field in ('relevant_file', 'issue_header', 'issue_content') + ): + continue relevant_file = issue.get('relevant_file', '').strip() issue_header = issue.get('issue_header', '').strip() if issue_header.lower() == 'possible bug': diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index b9633d8e07..3f6d4eddb5 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -6,6 +6,7 @@ from typing import List, Optional, Tuple from jinja2 import Environment, StrictUndefined +from pydantic import ValidationError from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler @@ -17,6 +18,7 @@ key_issue_fingerprint, key_issue_location_fingerprint, ) +from pr_agent.algo.output_models import PRReview from pr_agent.algo.pr_processing import ( PreparedPRDiff, add_ai_metadata_to_diff_files, @@ -839,6 +841,7 @@ async def _prepare_chunked_prediction(self, model: str, if isinstance(prediction, BaseException): raise prediction data = self._load_valid_review_yaml(prediction, source=f"review chunk {chunk_index + 1}") + self._validate_review_schema(data) raw_predictions.append(prediction) chunk_outputs.append(data) @@ -887,7 +890,50 @@ def _load_review_yaml(prediction: str) -> dict: keys_fix_yaml=["ticket_compliance_check", "estimated_effort_to_review_[1-5]:", "risk_level:", "merge_recommendation:", "security_concerns:", "key_issues_to_review:", "relevant_file:", "relevant_line:", "suggestion:"], - first_key='review', last_key='security_concerns') + first_key='review', last_key='security_concerns') + + def _validate_review_schema(self, data: object) -> bool: + try: + PRReview.model_validate(data) + except ValidationError as error: + first_error = error.errors()[0] + field_path = ".".join(str(part) for part in first_error.get("loc", ())) or "$" + value = None if first_error.get("type") == "missing" else first_error.get("input") + get_logger().warning( + "Review output failed schema validation", + artifact={ + "field": field_path, + "value": value, + }, + ) + return False + + if isinstance(data, dict) and isinstance(data.get("review"), dict): + review = data["review"] + required_fields = ( + ("ticket_compliance_check", "related_tickets"), + ("estimated_effort_to_review_[1-5]", "require_estimate_effort_to_review"), + ("risk_level", "require_risk_assessment"), + ("merge_recommendation", "require_merge_recommendation"), + ("review_priority_files", "require_priority_files"), + ("contribution_time_cost_estimate", "require_estimate_contribution_time_cost"), + ("score", "require_score"), + ("relevant_tests", "require_tests"), + ("insights_from_user_answers", "question_str"), + ("security_concerns", "require_security_review"), + ("todo_sections", "require_todo_scan"), + ("can_be_split", "require_can_be_split_review"), + ) + vars_ = getattr(self, "vars", {}) + for field_name, setting_name in required_fields: + if not vars_.get(setting_name) or field_name in review and review[field_name] is not None: + continue + get_logger().warning( + "Review output failed schema validation", + artifact={"field": f"review.{field_name}", "value": None}, + ) + return False + return True @classmethod def _load_valid_review_yaml(cls, prediction: str, *, source: str = "model response") -> dict: @@ -903,6 +949,8 @@ def _prepare_pr_review(self) -> str: the feedback. """ data = self.prediction_data if self.prediction_data is not None else self._load_review_yaml(self.prediction) + if self.prediction_data is None: + self._validate_review_schema(data) github_action_output(data, 'review') if not isinstance(data, dict) or not isinstance(data.get('review'), dict) or not data['review']: diff --git a/tests/unittest/test_convert_to_markdown.py b/tests/unittest/test_convert_to_markdown.py index a03a51c50a..bb1d5f77eb 100644 --- a/tests/unittest/test_convert_to_markdown.py +++ b/tests/unittest/test_convert_to_markdown.py @@ -1,6 +1,6 @@ # Generated by CodiumAI import textwrap -from unittest.mock import Mock +from unittest.mock import Mock, patch from pr_agent.algo.utils import PRReviewHeader, _expand_minute_suffix, convert_to_markdown_v2 from pr_agent.tools.pr_description import insert_br_after_x_chars @@ -123,6 +123,34 @@ def test_key_issues_to_review(self): assert convert_to_markdown_v2(input_data, git_provider=mock_git_provider).strip() == expected_output.strip() mock_git_provider.get_line_link.assert_called_with('src/utils.py', 30, 50) + def test_key_issue_with_non_string_text_is_skipped_without_render_error(self): + input_data = {'review': {'key_issues_to_review': [{ + 'relevant_file': 'src/utils.py', + 'issue_header': 'Code Smell', + 'issue_content': 123, + 'start_line': 30, + 'end_line': 50, + }]}} + + with patch('pr_agent.algo.utils.get_logger') as get_logger: + output = convert_to_markdown_v2(input_data) + + assert 'Recommended focus areas for review' in output + assert 'Code Smell' not in output + get_logger.return_value.exception.assert_not_called() + + def test_key_issue_with_omitted_text_keeps_empty_fallback(self): + input_data = {'review': {'key_issues_to_review': [{ + 'relevant_file': 'src/utils.py', + 'issue_content': 'The function needs review.', + 'start_line': 30, + 'end_line': 50, + }]}} + + output = convert_to_markdown_v2(input_data) + + assert '
    The function needs review.' in output + def test_ticket_compliance(self): input_data = {'review': { 'ticket_compliance_check': [ diff --git a/tests/unittest/test_pr_reviewer_core.py b/tests/unittest/test_pr_reviewer_core.py index 9068d80e75..89f57eb91b 100644 --- a/tests/unittest/test_pr_reviewer_core.py +++ b/tests/unittest/test_pr_reviewer_core.py @@ -213,6 +213,71 @@ def test_prepare_pr_review_leaves_original_content_unchanged_without_remaining_f assert "Review coverage" not in review +def test_prepare_pr_review_warns_on_invalid_model_output_without_changing_markdown(): + reviewer = _make_prediction_reviewer() + reviewer.prediction = "review:\n key_issues_to_review: wrong" + reviewer.git_provider.get_diff_files.return_value = [] + reviewer.git_provider.is_supported.return_value = False + reviewer.set_review_labels = MagicMock() + + with ( + patch("pr_agent.tools.pr_reviewer.load_yaml", return_value={"review": {"key_issues_to_review": "wrong"}}), + patch("pr_agent.tools.pr_reviewer.github_action_output"), + patch("pr_agent.tools.pr_reviewer.convert_to_markdown_v2", return_value="original review"), + patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger, + ): + review = reviewer._prepare_pr_review() + + assert review == "original review" + get_logger.return_value.warning.assert_called_once() + warning = get_logger.return_value.warning.call_args.kwargs + assert warning["artifact"] == {"field": "review.key_issues_to_review", "value": "wrong"} + + +def test_prepare_pr_review_does_not_warn_for_valid_model_output(): + reviewer = _make_prediction_reviewer() + reviewer.prediction = "review:\n key_issues_to_review: []" + reviewer.git_provider.get_diff_files.return_value = [] + reviewer.git_provider.is_supported.return_value = False + reviewer.set_review_labels = MagicMock() + + with ( + patch("pr_agent.tools.pr_reviewer.load_yaml", return_value={"review": {"key_issues_to_review": []}}), + patch("pr_agent.tools.pr_reviewer.github_action_output"), + patch("pr_agent.tools.pr_reviewer.convert_to_markdown_v2", return_value="original review"), + patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger, + ): + review = reviewer._prepare_pr_review() + + assert review == "original review" + get_logger.return_value.warning.assert_not_called() + + +def test_review_schema_requires_enabled_prompt_fields_only(): + reviewer = _make_prediction_reviewer() + reviewer.vars = {"require_tests": True} + with patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger: + assert reviewer._validate_review_schema({"review": {"key_issues_to_review": []}}) is False + + warning = get_logger.return_value.warning.call_args.kwargs + assert warning["artifact"] == {"field": "review.relevant_tests", "value": None} + + reviewer.vars = {"require_tests": False} + get_logger.return_value.warning.reset_mock() + assert reviewer._validate_review_schema({"review": {"key_issues_to_review": []}}) is True + get_logger.return_value.warning.assert_not_called() + + +def test_review_schema_reports_none_for_missing_fields(): + reviewer = _make_prediction_reviewer() + with patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger: + assert reviewer._validate_review_schema({"review": {}}) is False + + warning = get_logger.return_value.warning.call_args.kwargs + assert warning["artifact"]["field"] == "review.key_issues_to_review" + assert warning["artifact"]["value"] is None + + def test_prepare_pr_review_limits_coverage_footer_to_50_files(): reviewer = _make_prediction_reviewer() remaining_files = [f"file_{index}.py" for index in range(51)] diff --git a/tests/unittest/test_review_large_diff_chunking.py b/tests/unittest/test_review_large_diff_chunking.py index 06dfce44fd..3099566e2c 100644 --- a/tests/unittest/test_review_large_diff_chunking.py +++ b/tests/unittest/test_review_large_diff_chunking.py @@ -19,7 +19,7 @@ _TRACKED_KEYS = ("pr_reviewer.enable_large_pr_chunking", "pr_reviewer.max_number_of_calls") CHUNK_A = """review: - score: "90" + score: 90 key_issues_to_review: - relevant_file: | a.py @@ -34,7 +34,7 @@ """ CHUNK_B = """review: - score: "40" + score: 40 key_issues_to_review: [] security_concerns: | SQL injection: the query is built by string concatenation @@ -116,7 +116,7 @@ async def test_a_truncated_diff_is_reviewed_chunk_by_chunk_and_merged(chunking_e assert [call.args[1] for call in reviewer._get_prediction.await_args_list] == ["chunk-a", "chunk-b"] review = reviewer.prediction_data["review"] - assert review["score"] == "40" # the worst chunk sets the score + assert review["score"] == 40 # the worst chunk sets the score assert [issue["relevant_file"].strip() for issue in review["key_issues_to_review"]] == ["a.py"] assert review["security_concerns"].startswith("SQL injection:") assert reviewer.review_chunk_count == 2 @@ -194,7 +194,7 @@ async def test_a_chunk_that_fails_does_not_lose_the_chunks_that_succeeded(chunki ): await reviewer._prepare_prediction("model") - assert reviewer.prediction_data["review"]["score"] == "40" + assert reviewer.prediction_data["review"]["score"] == 40 assert reviewer.review_chunk_count == 2 assert reviewer.review_failed_chunk_count == 1 @@ -288,6 +288,29 @@ async def test_chunks_without_nonempty_reviews_fail_the_model_attempt(chunking_e assert reviewer.prediction_data is None +@pytest.mark.asyncio +async def test_invalid_chunk_emits_one_schema_warning_before_rendering(chunking_enabled): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=[ + "review:\n score: 101\n key_issues_to_review: []", + CHUNK_B, + ]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b"], [])), + patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger, + ): + await reviewer._prepare_prediction("model") + reviewer._prepare_pr_review() + + warnings = get_logger.return_value.warning.call_args_list + schema_warnings = [call for call in warnings if call.args == ("Review output failed schema validation",)] + assert len(schema_warnings) == 1 + assert schema_warnings[0].kwargs["artifact"] == {"field": "review.score", "value": 101} + + def _render_review(reviewer): reviewer.prediction = "review:\n summary: test" reviewer.git_provider.get_diff_files.return_value = [] From ce07a8f5d8ac19d3461bfc9cf33763ac1dffc084 Mon Sep 17 00:00:00 2001 From: Vladimir Roshchin <51440383+even-even@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:53:54 +0300 Subject: [PATCH 107/238] chore: up dynaconf to 3.3.5 and fixes warns (#3300) Co-authored-by: even-even <> Co-authored-by: IsmaelMartinez --- pr_agent/agent/pr_agent.py | 3 ++- pr_agent/algo/artifacts.py | 4 +++- pr_agent/config_loader.py | 6 +++++- pr_agent/servers/github_action_runner.py | 10 ++++++---- pyproject.toml | 2 +- uv.lock | 8 ++++---- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index f5097d702a..ad8e4ac56b 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -3,6 +3,7 @@ import shlex from functools import partial +import dynaconf from opentelemetry.trace import StatusCode from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler @@ -238,7 +239,7 @@ async def _run_command(self, pr_url, request, notify, span) -> bool: get_logger().info(f'User has set the response language to: {response_language}') for key in get_settings(): setting = get_settings().get(key) - if str(type(setting)) == "": + if isinstance(setting, dynaconf.DataDict): if hasattr(setting, 'extra_instructions'): current_extra_instructions = setting.extra_instructions diff --git a/pr_agent/algo/artifacts.py b/pr_agent/algo/artifacts.py index d69a9c4f00..1c12eb0073 100644 --- a/pr_agent/algo/artifacts.py +++ b/pr_agent/algo/artifacts.py @@ -2,6 +2,8 @@ from pathlib import Path from typing import Optional +import dynaconf + from pr_agent.config_loader import get_settings from pr_agent.log import get_logger @@ -149,7 +151,7 @@ def inject_artifact_context() -> None: separator = "\n======\n\n" for key in get_settings(): setting = get_settings().get(key) - if str(type(setting)) == "": + if isinstance(setting, dynaconf.DataDict): if key.lower() in target_tools and hasattr(setting, 'extra_instructions'): extra_instructions = str(setting.extra_instructions or "") if artifact_text not in extra_instructions: diff --git a/pr_agent/config_loader.py b/pr_agent/config_loader.py index bfcb1ec5bd..ccd8b4346f 100644 --- a/pr_agent/config_loader.py +++ b/pr_agent/config_loader.py @@ -12,7 +12,11 @@ dynconf_kwargs = {'core_loaders': [], # DISABLE default loaders, otherwise will load toml files more than once. 'loaders': ['pr_agent.custom_merge_loader', 'dynaconf.loaders.env_loader'], # Use a custom loader to merge sections, but overwrite their overlapping values. Also support ENV variables to take precedence. 'root_path': join(current_dir, "settings"), #Used for Dynaconf.find_file() - So that root path points to settings folder, since we disabled all core loaders. - 'merge_enabled': True # In case more than one file is sent, merge them. Must be set to True, otherwise, a .toml file with section [XYZ] overwrites the entire section of a previous .toml file's [XYZ] and we want it to only overwrite the overlapping fields under such section + # Multi-file section-field merging is done by pr_agent.custom_merge_loader itself (it accumulates fields + # across files and calls set() with a full section). Keeping dynaconf merge disabled makes settings.set() + # and SECTION__KEY env vars replace list values instead of appending to them (dynaconf >= 3.3 appends + # when merge_enabled is on); a section-level set() replaces the whole section, so always pass a full one. + "merge_enabled": False } global_settings = Dynaconf( envvar_prefix=False, diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index d406927b55..03df2ce482 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -3,6 +3,8 @@ import os from typing import Union +import dynaconf + from pr_agent.agent.pr_agent import PRAgent from pr_agent.algo.ai_handlers.litellm_helpers import ( DEFAULT_CALLBACK_TIMEOUT_SECONDS, @@ -190,7 +192,7 @@ async def run_action(): for key in get_settings(): setting = get_settings().get(key) - if str(type(setting)) == "": + if isinstance(setting, dynaconf.DataDict): if key.lower() in ['pr_description', 'pr_code_suggestions', 'pr_reviewer']: if hasattr(setting, 'extra_instructions'): extra_instructions = setting.extra_instructions @@ -328,8 +330,8 @@ async def run_action(): if url: # handle_line_comments returns an argv list for /ask line - # comments to bypass shell-style tokenisation; otherwise it - # returns the raw comment string. Only normalise when the + # comments to bypass shell-style tokenization; otherwise it + # returns the raw comment string. Only normalize when the # payload is a string, otherwise the argv list would be # passed through .strip().lower() and raise AttributeError. if isinstance(comment_body, str): @@ -430,7 +432,7 @@ def _inject_ci_conclusion(conclusion): target_tools = {str(t).lower() for t in target_tools} for key in get_settings(): setting = get_settings().get(key) - if str(type(setting)) == "": + if isinstance(setting, dynaconf.DataDict): if key.lower() in target_tools: if hasattr(setting, "extra_instructions"): extra_instructions = str(setting.extra_instructions or "") diff --git a/pyproject.toml b/pyproject.toml index 4b959637a1..3ae597d9c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "azure-identity==1.25.0", "boto3==1.43.78", "certifi>=2026.7.22", - "dynaconf>=3.2.13,<4", + "dynaconf>=3.3.5,<4", "fastapi>=0.141.1,<1", "GitPython>=3.1.59,<4", "starlette>=1.6.0,<2", diff --git a/uv.lock b/uv.lock index a4989b8854..dd986690ab 100644 --- a/uv.lock +++ b/uv.lock @@ -679,11 +679,11 @@ wheels = [ [[package]] name = "dynaconf" -version = "3.2.13" +version = "3.3.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/0e/05927cf459e73f8bf9a9277cbea6f2d5b7db8a5cc9dc1e20e7a5fbac1b90/dynaconf-3.2.13.tar.gz", hash = "sha256:d79e0189d97b3f226b8ebb1717e2ce05d1a05cdf6ea05de66d24625fdb5a0cbd", size = 283507, upload-time = "2026-03-17T19:38:47.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/e4/723ba469856bb493c948985e5bd562c8a65f2b93e70c896e9764ab76de00/dynaconf-3.3.5.tar.gz", hash = "sha256:a08f6ab44025034ef3c9f86b32548ab01efd4039094a74bd9f028a43c63d016f", size = 333426, upload-time = "2026-08-05T19:50:25.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/43/11d6e5d2c00bf000b5329717c74563bf76a9193f4a41cb0c4ef277dde4fa/dynaconf-3.2.13-py2.py3-none-any.whl", hash = "sha256:4305527aef4834bdba3e39479b23c005186e83fb85f65bcaa4bcea58fa26759b", size = 238041, upload-time = "2026-03-17T19:38:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/95/41/b38501c34c40c3261868a2af8ed5fffe3e6d889c680638708de27eb57788/dynaconf-3.3.5-py3-none-any.whl", hash = "sha256:b3b4a753d5c866fc85466b65fc58d4954f6ed7dc8aa67f2b15bcbfd85d184687", size = 271426, upload-time = "2026-08-05T19:50:23.802Z" }, ] [[package]] @@ -2549,7 +2549,7 @@ requires-dist = [ { name = "azure-identity", specifier = "==1.25.0" }, { name = "boto3", specifier = "==1.43.78" }, { name = "certifi", specifier = ">=2026.7.22" }, - { name = "dynaconf", specifier = ">=3.2.13,<4" }, + { name = "dynaconf", specifier = ">=3.3.5,<4" }, { name = "fastapi", specifier = ">=0.141.1,<1" }, { name = "giteapy", specifier = "==1.0.8" }, { name = "gitpython", specifier = ">=3.1.59,<4" }, From fab7f8b30be2310490c86ec1499621d046e1c200 Mon Sep 17 00:00:00 2001 From: hwan <3373484735@qq.com> Date: Tue, 15 Sep 2026 14:54:00 +0800 Subject: [PATCH 108/238] fix(github): report command failures in outcome reactions (#3171) Signed-off-by: hwan <3373484735@qq.com> --- pr_agent/agent/pr_agent.py | 78 +++++++++---- pr_agent/servers/github_app.py | 4 +- pr_agent/tools/pr_add_docs.py | 2 + pr_agent/tools/pr_generate_labels.py | 2 + tests/unittest/test_comment_reactions.py | 143 ++++++++++++++++++++++- 5 files changed, 202 insertions(+), 27 deletions(-) diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index ad8e4ac56b..50af9bb2f1 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -1,16 +1,18 @@ import asyncio +import copy import json import shlex from functools import partial import dynaconf from opentelemetry.trace import StatusCode +from starlette_context import context, request_cycle_context from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.cli_args import CliArgs from pr_agent.algo.utils import update_settings_from_args -from pr_agent.config_loader import get_settings +from pr_agent.config_loader import get_settings, global_settings from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.log import get_logger from pr_agent.telemetry.meter import get_commands_counter @@ -181,7 +183,9 @@ class PRAgent: def __init__(self, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler): self.ai_handler = ai_handler # handler factory passed to each tool when it is instantiated - async def _handle_request(self, pr_url, request, notify=None) -> bool: + async def _handle_request( + self, pr_url, request, notify=None, propagate_tool_errors: bool | None = None + ) -> bool: # Exceptions raised inside are caught below, but a BaseException (e.g. the # CancelledError a webhook timeout raises) still escapes the span, and the SDK # would auto-record its message and stacktrace — request content, so opt-in. @@ -194,7 +198,19 @@ async def _handle_request(self, pr_url, request, notify=None) -> bool: if get_settings().get("OTEL.INCLUDE_PR_URL", False): span.set_attribute("pr_agent.pr_url", pr_url) try: - return await self._run_command(pr_url, request, notify, span) + if propagate_tool_errors is None: + return await self._run_command(pr_url, request, notify, span) + try: + context["settings"] + except Exception: + # Create request-local settings before awaiting commands outside middleware. + with request_cycle_context({"settings": copy.deepcopy(global_settings)}): + return await self._run_command( + pr_url, request, notify, span, propagate_tool_errors=propagate_tool_errors + ) + return await self._run_command( + pr_url, request, notify, span, propagate_tool_errors=propagate_tool_errors + ) except Exception as e: get_logger().exception("Failed to process the command.") # Status carries no description: it is free text, and the exception @@ -206,7 +222,9 @@ async def _handle_request(self, pr_url, request, notify=None) -> bool: span.record_exception(e) return False - async def _run_command(self, pr_url, request, notify, span) -> bool: + async def _run_command( + self, pr_url, request, notify, span, propagate_tool_errors: bool | None = None + ) -> bool: # First, apply repo specific settings if exists apply_repo_settings(pr_url) @@ -277,26 +295,42 @@ async def _run_command(self, pr_url, request, notify, span) -> bool: span.set_attribute("pr_agent.command", action) get_commands_counter().add(1, {"pr_agent.command": action, "vcs.provider.name": _git_provider}) - with get_logger().contextualize(command=action, pr_url=pr_url): - get_logger().info("PR-Agent request handler started", analytics=True) - if action == "answer": - if notify: - notify() - await PRReviewer(pr_url, is_answer=True, args=args, ai_handler=self.ai_handler).run() - elif action == "auto_review": - await PRReviewer(pr_url, is_auto=True, args=args, ai_handler=self.ai_handler).run() - else: - if notify: - notify() - - await command2class[action](pr_url, ai_handler=self.ai_handler, args=args).run() - - span.set_status(StatusCode.OK) - return True + settings = get_settings() + if propagate_tool_errors is not None: + # Apply this after repository and command settings so callers that require an honest + # result cannot be overridden by either source. Restore it below for request isolation. + previous_propagation = settings.get("CONFIG.PROPAGATE_TOOL_ERRORS", False) + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", propagate_tool_errors) + try: + with get_logger().contextualize(command=action, pr_url=pr_url): + get_logger().info("PR-Agent request handler started", analytics=True) + if action == "answer": + if notify: + notify() + await PRReviewer(pr_url, is_answer=True, args=args, ai_handler=self.ai_handler).run() + elif action == "auto_review": + await PRReviewer(pr_url, is_auto=True, args=args, ai_handler=self.ai_handler).run() + else: + if notify: + notify() + + await command2class[action](pr_url, ai_handler=self.ai_handler, args=args).run() + + span.set_status(StatusCode.OK) + return True + finally: + if propagate_tool_errors is not None: + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", previous_propagation) - async def handle_request(self, pr_url, request, notify=None) -> bool: + async def handle_request( + self, pr_url, request, notify=None, propagate_tool_errors: bool | None = None + ) -> bool: try: - return await self._handle_request(pr_url, request, notify) + if propagate_tool_errors is None: + return await self._handle_request(pr_url, request, notify) + return await self._handle_request( + pr_url, request, notify, propagate_tool_errors=propagate_tool_errors + ) except Exception: # _handle_request already catches command failures and annotates the span; # this is the outer contract every caller relies on — webhook handlers and diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index 06478f3a7c..fc05107fe8 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -147,7 +147,9 @@ async def handle_comments_on_pr(body: Dict[str, Any], get_logger().info(f"Processing comment on PR {api_url=}, comment_body={comment_body}") succeeded = await agent.handle_request( api_url, comment_body, - notify=lambda: provider.add_eyes_reaction(comment_id, disable_eyes=disable_eyes)) + notify=lambda: provider.add_eyes_reaction(comment_id, disable_eyes=disable_eyes), + # Preserve compatibility mode while exposing failures to outcome reactions. + propagate_tool_errors=True) # Optional, and disabled by default: tell the author how the command ended without # adding another comment to the thread. provider.react_to_outcome(comment_id, bool(succeeded)) diff --git a/pr_agent/tools/pr_add_docs.py b/pr_agent/tools/pr_add_docs.py index 4c916b7f11..ca76ee577b 100644 --- a/pr_agent/tools/pr_add_docs.py +++ b/pr_agent/tools/pr_add_docs.py @@ -72,6 +72,8 @@ async def run(self): self.push_inline_docs(data) except Exception as e: get_logger().error(f"Failed to generate code documentation for PR, error: {e}") + if get_settings().config.get("propagate_tool_errors", False): + raise async def _prepare_prediction(self, model: str): get_logger().info('Getting PR diff...') diff --git a/pr_agent/tools/pr_generate_labels.py b/pr_agent/tools/pr_generate_labels.py index cf7a5230d7..68781d0b17 100644 --- a/pr_agent/tools/pr_generate_labels.py +++ b/pr_agent/tools/pr_generate_labels.py @@ -113,6 +113,8 @@ async def run(self): self.git_provider.remove_initial_comment() except Exception as e: get_logger().error(f"Error generating PR labels {self.pr_id}: {e}") + if get_settings().config.get("propagate_tool_errors", False): + raise return "" diff --git a/tests/unittest/test_comment_reactions.py b/tests/unittest/test_comment_reactions.py index 7df0f40c37..ee0852f35d 100644 --- a/tests/unittest/test_comment_reactions.py +++ b/tests/unittest/test_comment_reactions.py @@ -4,12 +4,13 @@ a long command could not tell whether it had finished. `add_reaction` is the provider-level primitive; `add_eyes_reaction` and `react_to_outcome` are the two policies built on it. """ +import asyncio from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from pr_agent.config_loader import get_settings +from pr_agent.config_loader import get_settings, global_settings from pr_agent.git_providers.git_provider import GitProvider, get_reaction_setting from pr_agent.git_providers.github_provider import GithubProvider from pr_agent.git_providers.gitlab_provider import GitLabProvider @@ -309,7 +310,7 @@ async def test_a_successful_comment_command_is_marked(reactions, comment_handler github_app, provider = comment_handler agent = SimpleNamespace() - async def handle_request(api_url, command, notify=None): + async def handle_request(api_url, command, notify=None, **_kwargs): notify() return True @@ -326,7 +327,7 @@ async def test_a_failed_comment_command_is_marked(reactions, comment_handler): github_app, provider = comment_handler agent = SimpleNamespace() - async def handle_request(api_url, command, notify=None): + async def handle_request(api_url, command, notify=None, **_kwargs): notify() return False @@ -343,7 +344,7 @@ async def test_the_default_configuration_adds_only_the_start_reaction(reactions, github_app, provider = comment_handler agent = SimpleNamespace() - async def handle_request(api_url, command, notify=None): + async def handle_request(api_url, command, notify=None, **_kwargs): notify() return True @@ -353,3 +354,137 @@ async def handle_request(api_url, command, notify=None): assert provider.reactions == [(4242, "eyes")] assert provider.removed == [] + + +@pytest.mark.asyncio +async def test_a_swallowed_tool_failure_gets_a_failure_outcome( + reactions, comment_handler, monkeypatch +): + """Keep compatibility-swallowed tool failures from becoming success reactions.""" + import pr_agent.agent.pr_agent as pr_agent_module + + reactions(success="hooray", failure="confused") + monkeypatch.setattr(get_settings().config, "propagate_tool_errors", False, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + observed_propagation = [] + + class SwallowingReviewTool: + def __init__(self, _pr_url, ai_handler=None, args=None): + pass + + async def run(self): + observed_propagation.append( + get_settings().config.get("propagate_tool_errors", False) + ) + try: + raise RuntimeError("provider unavailable") + except RuntimeError: + if get_settings().config.get("propagate_tool_errors", False): + raise + + monkeypatch.setitem(pr_agent_module.command2class, "review", SwallowingReviewTool) + + github_app, provider = comment_handler + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + await github_app.handle_comments_on_pr( + _comment_event(), "issue_comment", "user", "1", "created", {}, agent + ) + + assert observed_propagation == [True] + assert provider.reactions == [(4242, "eyes"), (4242, "confused")] + assert provider.removed == [(4242, 1)] + assert get_settings().config.get("propagate_tool_errors") is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["add_docs", "generate_labels"]) +async def test_documentation_and_label_failures_get_failure_outcomes( + reactions, comment_handler, monkeypatch, command +): + """Verify that documentation and label command failures get failure reactions.""" + import pr_agent.agent.pr_agent as pr_agent_module + from pr_agent.tools import pr_add_docs, pr_generate_labels + + reactions(success="hooray", failure="confused") + settings = get_settings() + monkeypatch.setattr(settings.config, "propagate_tool_errors", False, raising=False) + monkeypatch.setattr(settings.config, "publish_output", False, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + tool_module = pr_add_docs if command == "add_docs" else pr_generate_labels + tool_class = pr_agent_module.command2class[command] + + def init_tool(self, *_args, **_kwargs): + self.git_provider = MagicMock() + self.pr_id = "org/repo#1" + + async def fail_before_publishing(*_args, **_kwargs): + raise RuntimeError("provider unavailable") + + monkeypatch.setattr(tool_class, "__init__", init_tool) + monkeypatch.setattr(tool_module, "retry_with_fallback_models", fail_before_publishing) + + github_app, provider = comment_handler + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + await github_app.handle_comments_on_pr( + _comment_event(f"/{command}"), "issue_comment", "user", "1", "created", {}, agent + ) + + assert provider.reactions == [(4242, "eyes"), (4242, "confused")] + assert provider.removed == [(4242, 1)] + assert get_settings().config.get("propagate_tool_errors") is False + + +@pytest.mark.asyncio +async def test_contextless_propagation_override_isolated_from_concurrent_requests(monkeypatch): + import pr_agent.agent.pr_agent as pr_agent_module + + assert get_settings() is global_settings + monkeypatch.setattr(global_settings.config, "propagate_tool_errors", False, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + override_started = asyncio.Event() + default_observed = asyncio.Event() + observed = {} + + class WaitingTool: + def __init__(self, pr_url, ai_handler=None, args=None): + self.pr_url = pr_url + + async def run(self): + observed[self.pr_url] = get_settings().config.get("propagate_tool_errors", False) + if self.pr_url == "override": + override_started.set() + await default_observed.wait() + else: + await override_started.wait() + observed["default_during_override"] = get_settings().config.get( + "propagate_tool_errors", False + ) + default_observed.set() + + monkeypatch.setitem(pr_agent_module.command2class, "review", WaitingTool) + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + override_result, default_result = await asyncio.gather( + agent.handle_request("override", ["review"], propagate_tool_errors=True), + agent.handle_request("default", ["review"]), + ) + + assert override_result is True + assert default_result is True + assert observed == { + "override": True, + "default": False, + "default_during_override": False, + } + assert global_settings.config.get("propagate_tool_errors") is False From e2935b6b8196c911d8ef44aa029aec37113faba5 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:57:43 -0400 Subject: [PATCH 109/238] fix(bitbucket): send inline comment ranges with start_to (#3384) --- pr_agent/git_providers/bitbucket_provider.py | 25 ++++--- tests/unittest/test_bitbucket_provider.py | 71 +++++++++++++++++++- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index 413548db53..cbc301ecb3 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -434,7 +434,6 @@ def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_ def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str | int, original_suggestion=None) -> bool: - body = self.limit_output_characters(body, self.max_comment_length) # The base contract passes the line's text; publish_inline_comments passes an already resolved line number. if not isinstance(relevant_line_in_file, int): comment = self.create_inline_comment(body, relevant_file, relevant_line_in_file) @@ -443,14 +442,23 @@ def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in "to publish an inline comment") return False relevant_file, relevant_line_in_file = comment["path"], comment["position"] + return self._post_inline_comment(body, relevant_file, relevant_line_in_file) + + def _post_inline_comment(self, body: str, relevant_file: str, from_line: int, to_line: int = None) -> bool: + # Bitbucket Cloud anchors a span with 'start_to' and 'to'. Anything that is not a real + # span is posted as a single-line comment. + body = self.limit_output_characters(body, self.max_comment_length) + if isinstance(to_line, int) and to_line > from_line: + inline = {"start_to": from_line, "to": to_line, "path": relevant_file} + location = f"lines {from_line}-{to_line}" + else: + inline = {"to": from_line, "path": relevant_file} + location = f"line {from_line}" payload = json.dumps({ "content": { "raw": body, }, - "inline": { - "to": relevant_line_in_file, - "path": relevant_file - }, + "inline": inline, }) try: response = requests.request( @@ -459,7 +467,7 @@ def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in response.raise_for_status() except Exception as e: get_logger().error( - f"Failed to publish inline comment to '{relevant_file}' at line {relevant_line_in_file}, error: {e}") + f"Failed to publish inline comment to '{relevant_file}' at {location}, error: {e}") return False return True @@ -494,11 +502,12 @@ def publish_inline_comments(self, comments: list[dict]) -> bool: publishable_count = 0 published_count = 0 for comment in comments: + to_line = None if 'position' in comment: from_line = comment['position'] elif 'start_line' in comment: # multi-line comment - # note that bitbucket does not seem to support range - only a comment on a single line - https://community.developer.atlassian.com/t/api-post-endpoint-for-inline-pull-request-comments/60452 from_line = comment['start_line'] + to_line = comment.get('line') elif 'line' in comment: # single-line comment from_line = comment['line'] else: @@ -506,7 +515,7 @@ def publish_inline_comments(self, comments: list[dict]) -> bool: continue publishable_count += 1 - if self.publish_inline_comment(comment['body'], comment['path'], from_line): + if self._post_inline_comment(comment['body'], comment['path'], from_line, to_line): published_count += 1 # A partial failure must not report failure: the caller republishes the whole diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index 3cc5562abb..d837cc9650 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -25,12 +25,12 @@ def _added_file(filename="src/example.py", lines=("a = 1", "b = 2", "c = 3")): class TestBitbucketProvider: @staticmethod - def _code_suggestion(line: int): + def _code_suggestion(line: int, end_line: int = None): return { "body": "**Suggestion:** fix it", "relevant_file": "src/example.py", "relevant_lines_start": line, - "relevant_lines_end": line, + "relevant_lines_end": line if end_line is None else end_line, } @staticmethod @@ -636,6 +636,73 @@ def test_publish_code_suggestions_treats_unpublishable_list_as_successful_noop(s assert result is True request.assert_not_called() + def test_publish_code_suggestions_anchors_a_multi_line_suggestion_to_the_whole_range(self): + provider = self._provider_for_code_suggestions() + response = self._inline_comment_response(201) + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_code_suggestions([self._code_suggestion(10, 16)]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "**Suggestion:** fix it"}, ' + '"inline": {"start_to": 10, "to": 16, "path": "src/example.py"}}', + headers=provider.headers, + ) + + def test_publish_code_suggestions_keeps_a_single_line_suggestion_on_one_line(self): + provider = self._provider_for_code_suggestions() + response = self._inline_comment_response(201) + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_code_suggestions([self._code_suggestion(2)]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "**Suggestion:** fix it"}, "inline": {"to": 2, "path": "src/example.py"}}', + headers=provider.headers, + ) + + @pytest.mark.parametrize("end", [{"line": 7}, {"line": 6}, {"line": None}, {}], + ids=["end equal", "end below", "end null", "end absent"]) + def test_publish_inline_comments_stays_single_line_without_a_real_range(self, end): + # Only a genuine span may become 'start_to'; everything else stays a single-line comment. + provider = self._provider_for_code_suggestions() + response = self._inline_comment_response(201) + comment = {"body": "watch out", "path": "src/example.py", "start_line": 7, **end} + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_inline_comments([comment]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "watch out"}, "inline": {"to": 7, "path": "src/example.py"}}', + headers=provider.headers, + ) + + def test_publish_inline_comments_truncates_a_multi_line_body(self): + provider = self._provider_for_code_suggestions() + provider.max_comment_length = 10 + response = self._inline_comment_response(201) + comment = {"body": "x" * 50, "path": "src/example.py", "start_line": 3, "line": 5} + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_inline_comments([comment]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "xxxxxxx..."}, ' + '"inline": {"start_to": 3, "to": 5, "path": "src/example.py"}}', + headers=provider.headers, + ) def test_get_issue_comments_normalizes_cloud_comments(self): provider = BitbucketProvider.__new__(BitbucketProvider) From 888e9f3f5163fae6a7491c2e85fc045525f6e324 Mon Sep 17 00:00:00 2001 From: JWM <62558419+JokeWaumans@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:59:34 +0200 Subject: [PATCH 110/238] fix(gitlab): resolve relative submodule URLs and read .gitmodules at the MR head (#3379) --- .../usage-guide/additional_configurations.md | 2 + pr_agent/git_providers/gitlab_provider.py | 71 ++++++- tests/unittest/test_gitlab_provider.py | 194 ++++++++++++++++++ 3 files changed, 257 insertions(+), 10 deletions(-) diff --git a/docs/docs/usage-guide/additional_configurations.md b/docs/docs/usage-guide/additional_configurations.md index cae1b8f909..b0c8475f3e 100644 --- a/docs/docs/usage-guide/additional_configurations.md +++ b/docs/docs/usage-guide/additional_configurations.md @@ -149,6 +149,8 @@ expand_submodule_diffs = true When enabled, PR-Agent will fetch and attach diffs from the submodule repositories. The default is `false` to avoid extra GitLab API calls. +Submodule URLs in `.gitmodules` may be absolute (`https://`, `ssh://`, `git@host:`) or relative (`../group/repo.git`). Relative URLs are resolved against the merge request's project path the same way git does, so submodules that live in a sibling group on the same GitLab instance are expanded too. + ## Post the review as a GitLab thread By default, PR-Agent posts the `/review` summary as a plain note. To post it as a resolvable thread (GitLab discussion) instead, enable (default: `false`): diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index e8208de700..34b4d229c2 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -1,4 +1,5 @@ import difflib +import posixpath import re import urllib.parse from datetime import datetime, timezone @@ -202,20 +203,38 @@ def __init__(self, merge_request_url: Optional[str] = None, incremental: Optiona def _get_gitmodules_map(self) -> dict[str, str]: """ Return {submodule_path -> repo_url} from '.gitmodules' (best effort). - Tries target branch first, then source branch. Always returns text. + Reads the MR head commit first (it carries the submodule URLs the MR introduces, e.g. after a + submodule was moved), then falls back to the source branch, the MR base commit and the target + branch. Always returns text. """ try: proj = self.gl.projects.get(self.id_project) except Exception: return {} + diff_refs = getattr(self.mr, "diff_refs", None) + diff_refs = diff_refs if isinstance(diff_refs, dict) else {} + + def _source_project(): + # For fork MRs the source branch lives in the source project, not the target project. If that + # project cannot be fetched, skip the source read rather than looking the fork's branch name up + # in the target project, where a same-named branch would yield unrelated '.gitmodules' content. + source_project_id = getattr(self.mr, "source_project_id", None) + if not source_project_id or str(source_project_id) == str(getattr(proj, "id", None)): + return proj + try: + return self.gl.projects.get(source_project_id) + except Exception as e: + get_logger().warning(f"[submodule] cannot fetch source project '{source_project_id}': {e}") + return None + import base64 - def _read_text(ref: str | None) -> str | None: - if not ref: + def _read_text(project, ref: str | None) -> str | None: + if project is None or not ref: return None try: - f = proj.files.get(file_path=".gitmodules", ref=ref) + f = project.files.get(file_path=".gitmodules", ref=ref) except Exception: return None @@ -239,9 +258,13 @@ def _read_text(ref: str | None) -> str | None: return None + # The MR head SHA is immutable (unlike the branch, which may be force-pushed meanwhile) and is + # reachable in the target project also for fork MRs (refs/merge-requests//head). content = ( - _read_text(getattr(self.mr, "target_branch", None)) - or _read_text(getattr(self.mr, "source_branch", None)) + _read_text(proj, diff_refs.get("head_sha")) + or _read_text(_source_project(), getattr(self.mr, "source_branch", None)) + or _read_text(proj, diff_refs.get("base_sha")) + or _read_text(proj, getattr(self.mr, "target_branch", None)) ) if not content: return {} @@ -271,12 +294,35 @@ def _read_text(ref: str | None) -> str | None: out[path] = url return out - def _url_to_project_path(self, url: str) -> str | None: + def _superproject_path(self) -> str | None: + """ + Return the MR project's 'group/subgroup/.../repo' path, used as the base for relative submodule URLs. + """ + id_project = str(self.id_project or "") + if "/" in id_project: + return id_project + try: + return getattr(self.gl.projects.get(self.id_project), "path_with_namespace", None) or None + except Exception as e: + get_logger().warning(f"[submodule] cannot look up project path for project '{id_project}': {e}") + return None + + def _url_to_project_path(self, url: str, base_project_path: str | None = None) -> str | None: """ - Convert ssh/https GitLab URL to 'group/subgroup/repo' project path. + Convert ssh/https GitLab URL to a 'group/subgroup/.../repo' project path (any nesting depth). + + Relative URLs ('../group/repo.git', './repo.git') are resolved the way git does: against the + superproject's own URL, so '../' first steps out of the superproject repository itself. """ try: - if url.startswith("git@") and ":" in url: + if url.startswith(("./", "../")): + if not base_project_path: + return None + rel_path = urllib.parse.urlparse(url).path + path = posixpath.normpath(posixpath.join(base_project_path.strip("/"), rel_path)) + if path in (".", "..") or path.startswith("../"): + return None + elif url.startswith("git@") and ":" in url: path = url.split(":", 1)[1] else: path = urllib.parse.urlparse(url).path.lstrip("/") @@ -368,6 +414,7 @@ def _expand_submodule_changes(self, changes: list[dict]) -> list[dict]: return changes out = list(changes) + base_project_path = None for ch in changes: patch = ch.get("diff") or "" if "Subproject commit" not in patch: @@ -386,7 +433,11 @@ def _expand_submodule_changes(self, changes: list[dict]) -> list[dict]: get_logger().warning(f"[submodule] no url for '{sub_path}' in .gitmodules (skip)") continue - proj_path = self._url_to_project_path(repo_url) + if repo_url.startswith(("./", "../")) and base_project_path is None: + base_project_path = self._superproject_path() or "" + if not base_project_path: + get_logger().warning("[submodule] superproject path unknown; relative submodule urls are skipped") + proj_path = self._url_to_project_path(repo_url, base_project_path) if not proj_path: get_logger().warning(f"[submodule] cannot parse project path from url '{repo_url}' (skip)") continue diff --git a/tests/unittest/test_gitlab_provider.py b/tests/unittest/test_gitlab_provider.py index 98d6b83c37..8c1c60992b 100644 --- a/tests/unittest/test_gitlab_provider.py +++ b/tests/unittest/test_gitlab_provider.py @@ -278,6 +278,119 @@ def test_get_gitmodules_map_parsing(self, gitlab_provider, mock_project): "libs/b": "git@gitlab.com:b.git", } + @staticmethod + def _gitmodules_file(url, path="libs/a"): + file_obj = MagicMock(ProjectFile) + file_obj.decode.return_value = f"[submodule \"{path}\"]\n path = {path}\n url = {url}\n" + return file_obj + + @staticmethod + def _mr(diff_refs=None, source_project_id=1): + mr = MagicMock() + mr.source_branch = "feature" + mr.target_branch = "main" + mr.diff_refs = diff_refs + mr.source_project_id = source_project_id + return mr + + def test_get_gitmodules_map_reads_mr_head_sha(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs={"head_sha": "h1", "base_sha": "b1"}, source_project_id=99) + mock_project.id = 1 + # The source branch moved on since the MR diff was fetched; its content must not be used. + mock_project.files.get.side_effect = lambda file_path, ref: { + "h1": self._gitmodules_file("../new/a.git"), + "feature": self._gitmodules_file("../newer/a.git"), + "b1": self._gitmodules_file("../old/a.git"), + "main": self._gitmodules_file("../old/a.git"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../new/a.git"} + mock_project.files.get.assert_called_once_with(file_path=".gitmodules", ref="h1") + # The fork project is not looked up when the head commit could be read from the target project. + assert all(call.args[0] != 99 for call in gitlab_provider.gl.projects.get.call_args_list) + + def test_get_gitmodules_map_falls_back_to_source_branch_without_diff_refs(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs=None, source_project_id=mock_project.id) + mock_project.files.get.side_effect = lambda file_path, ref: { + "feature": self._gitmodules_file("../new/a.git"), + "main": self._gitmodules_file("../old/a.git"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../new/a.git"} + + @pytest.mark.parametrize("available_ref,expected", [ + ("b1", "../base/a.git"), + ("main", "../old/a.git"), + ]) + def test_get_gitmodules_map_falls_back_to_base_sha_then_target_branch(self, gitlab_provider, mock_project, + available_ref, expected): + gitlab_provider.mr = self._mr(diff_refs={"head_sha": "h1", "base_sha": "b1"}, source_project_id=mock_project.id) + files = {"b1": "../base/a.git", "main": "../old/a.git"} + + def _files_get(file_path, ref): + if ref == available_ref: + return self._gitmodules_file(files[ref]) + raise GitlabGetError("404 File Not Found") + + mock_project.files.get.side_effect = _files_get + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": expected} + + def test_get_gitmodules_map_reads_fork_source_project(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs=None, source_project_id=99) + mock_project.id = 1 + fork = MagicMock() + fork.id = 99 + fork.files.get.return_value = self._gitmodules_file("../fork/a.git") + mock_project.files.get.return_value = self._gitmodules_file("../old/a.git") + gitlab_provider.gl.projects.get.side_effect = lambda pid, **kw: fork if str(pid) == "99" else mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../fork/a.git"} + fork.files.get.assert_called_once_with(file_path=".gitmodules", ref="feature") + mock_project.files.get.assert_not_called() + + def test_get_gitmodules_map_failed_fork_lookup_skips_source_read(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs=None, source_project_id=99) + mock_project.id = 1 + # The target project happens to have a branch named like the fork's source branch. + mock_project.files.get.side_effect = lambda file_path, ref: { + "feature": self._gitmodules_file("../unrelated/a.git"), + "main": self._gitmodules_file("../old/a.git"), + }[ref] + + def _projects_get(pid, **kw): + if str(pid) == "99": + raise GitlabGetError("404 Project Not Found") + return mock_project + + gitlab_provider.gl.projects.get.side_effect = _projects_get + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_logger: + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../old/a.git"} + + mock_project.files.get.assert_called_once_with(file_path=".gitmodules", ref="main") + assert "99" in mock_logger.return_value.warning.call_args.args[0] + + def test_expand_submodule_changes_uses_mr_head_url(self, gitlab_provider, mock_project): + gitlab_provider.id_project = "group/repo" + gitlab_provider.mr = self._mr(diff_refs={"head_sha": "h1", "base_sha": "b1"}, source_project_id=mock_project.id) + mock_project.files.get.side_effect = lambda file_path, ref: { + "h1": self._gitmodules_file("../new/a.git", path="src/lib_a"), + "main": self._gitmodules_file("../old/a.git", path="src/lib_a"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: {"GITLAB.EXPAND_SUBMODULE_DIFFS": True}.get(key, default) + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings), \ + patch.object(gitlab_provider, "_compare_submodule", return_value=[]) as m_cmp: + gitlab_provider._expand_submodule_changes([self._submodule_bump()]) + + m_cmp.assert_called_once_with("group/new/a", "aaa1111", "bbb2222") + def test_project_by_path_requires_exact_match(self, gitlab_provider): gitlab_provider.gl.projects.get.reset_mock() gitlab_provider.gl.projects.get.side_effect = Exception("not found") @@ -295,6 +408,87 @@ def test_project_by_path_requires_exact_match(self, gitlab_provider): assert list_kwargs["membership"] is True assert all(call.args[0] != fake.id for call in gitlab_provider.gl.projects.get.call_args_list) + @pytest.mark.parametrize("url,base,expected", [ + ("https://gitlab.com/group/repo.git", None, "group/repo"), + ("git@gitlab.com:group/sub/repo.git", None, "group/sub/repo"), + ("ssh://git@gitlab.com/group/repo.git", None, "group/repo"), + # Relative URLs resolve against the superproject path. + ("../../../../libs/lib_a.git", "group/subgroup/nested/repo", "libs/lib_a"), + ("../../libs/lib_a.git", "group/subgroup/nested/repo", "group/subgroup/libs/lib_a"), + ("../lib_b.git", "group/sub/repo", "group/sub/lib_b"), + ("./nested/lib_c.git", "group/repo", "group/repo/nested/lib_c"), + # Query/fragment are dropped for relative URLs just like for absolute ones. + ("../lib_b.git?ref=main", "group/repo", "group/lib_b"), + ("https://gitlab.com/group/repo.git#main", None, "group/repo"), + # Relative URL without a superproject to resolve against. + ("../lib_b.git", None, None), + # Relative URL that climbs past the instance root. + ("../../../lib_b.git", "group/repo", None), + ]) + def test_url_to_project_path(self, gitlab_provider, url, base, expected): + assert gitlab_provider._url_to_project_path(url, base) == expected + + def test_superproject_path_prefers_id_project_path(self, gitlab_provider): + gitlab_provider.id_project = "group/sub/repo" + gitlab_provider.gl.projects.get.reset_mock() + + assert gitlab_provider._superproject_path() == "group/sub/repo" + gitlab_provider.gl.projects.get.assert_not_called() + + def test_superproject_path_resolves_numeric_project_id(self, gitlab_provider, mock_project): + gitlab_provider.id_project = "42" + mock_project.path_with_namespace = "group/sub/repo" + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._superproject_path() == "group/sub/repo" + + def test_superproject_path_logs_lookup_failure(self, gitlab_provider): + gitlab_provider.id_project = "42" + gitlab_provider.gl.projects.get.side_effect = GitlabGetError("401 Unauthorized") + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_logger: + assert gitlab_provider._superproject_path() is None + + warning = mock_logger.return_value.warning + warning.assert_called_once() + assert "42" in warning.call_args.args[0] + assert "401 Unauthorized" in warning.call_args.args[0] + + def _submodule_bump(self, path="src/lib_a"): + return {"new_path": path, "old_path": path, + "diff": "-Subproject commit aaa1111\n+Subproject commit bbb2222\n", + "new_file": False, "deleted_file": False, "renamed_file": False} + + def test_expand_submodule_changes_resolves_relative_url(self, gitlab_provider): + gitlab_provider.id_project = "group/subgroup/nested/repo" + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: {"GITLAB.EXPAND_SUBMODULE_DIFFS": True}.get(key, default) + sub_diffs = [{"old_path": "src/a.c", "new_path": "src/a.c", "diff": "@@ -1 +1 @@\n-x\n+y\n"}] + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings), \ + patch.object(gitlab_provider, "_get_gitmodules_map", + return_value={"src/lib_a": "../../../../libs/lib_a.git"}), \ + patch.object(gitlab_provider, "_compare_submodule", return_value=sub_diffs) as m_cmp: + out = gitlab_provider._expand_submodule_changes([self._submodule_bump()]) + + m_cmp.assert_called_once_with("libs/lib_a", "aaa1111", "bbb2222") + assert [c["new_path"] for c in out] == ["src/lib_a", "src/lib_a/src/a.c"] + + def test_expand_submodule_changes_skips_unresolvable_relative_url(self, gitlab_provider): + gitlab_provider.id_project = "group/repo" + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: {"GITLAB.EXPAND_SUBMODULE_DIFFS": True}.get(key, default) + changes = [self._submodule_bump()] + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings), \ + patch.object(gitlab_provider, "_get_gitmodules_map", + return_value={"src/lib_a": "../../../libs/lib_a.git"}), \ + patch.object(gitlab_provider, "_compare_submodule") as m_cmp: + out = gitlab_provider._expand_submodule_changes(changes) + + m_cmp.assert_not_called() + assert out == changes + def test_compare_submodule_cached(self, gitlab_provider): proj = MagicMock() proj.repository_compare.return_value = {"diffs": [{"diff": "d"}]} From e55c1303ab02c2a404f481ca11043db70e8b6814 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Tue, 15 Sep 2026 02:03:12 -0500 Subject: [PATCH 111/238] fix: preserve /help answers with malformed citations (#3343) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> Co-authored-by: IsmaelMartinez --- pr_agent/tools/pr_help_message.py | 89 +++++++++++-- .../unittest/test_pr_help_message_fallback.py | 122 +++++++++++++++++- .../test_pr_help_message_rendering.py | 101 +++++++++++++++ 3 files changed, 297 insertions(+), 15 deletions(-) diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 70c1f7c881..96c10adfad 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -38,9 +38,14 @@ def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = async def _prepare_prediction(self, model: str): variables = copy.deepcopy(self.vars) - system_prompt, user_prompt = self._fit_prompts(variables, model) + system_prompt, user_prompt, fitted_snippets = self._fit_prompts(variables, model) response, finish_reason = await self.ai_handler.chat_completion( model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) + self._model_visible_docs_files = self._get_fitted_docs_files( + variables.get("snippets", ""), + fitted_snippets, + getattr(self, "_available_docs_files", set()), + ) return response @staticmethod @@ -166,7 +171,7 @@ def render(snippets): full_prompts = render(raw_snippets) if self._count_prompt_tokens(model, *full_prompts) <= prompt_budget: - return full_prompts + return full_prompts[0], full_prompts[1], raw_snippets empty_prompts = render("") if self._count_prompt_tokens(model, *empty_prompts) > prompt_budget: @@ -185,14 +190,43 @@ def render(snippets): get_logger().warning( f"Documentation was clipped for /help to fit the {prompt_budget}-token input limit for {model}" ) - return candidate_prompts + return candidate_prompts[0], candidate_prompts[1], candidate next_keep_chars = max(1, int(keep_chars * prompt_budget / candidate_tokens)) keep_chars = min(keep_chars - 1, next_keep_chars) get_logger().warning( f"Documentation was clipped for /help to fit the {prompt_budget}-token input limit for {model}" ) - return marker_prompts + return marker_prompts[0], marker_prompts[1], TRUNCATION_MARKER + + @staticmethod + def _get_fitted_docs_files( + raw_snippets: str, + fitted_snippets: str, + available_docs_files: set[str], + ) -> set[str]: + if not raw_snippets or not fitted_snippets or not available_docs_files: + return set() + + section_starts = [] + for file_name in available_docs_files: + section_prefix = f"==file name==\n\n/{file_name}\n\n==file content==\n\n" + section_start = raw_snippets.find(section_prefix) + if section_start >= 0: + section_starts.append((section_start, file_name)) + section_starts.sort() + + fitted_docs_files = set() + for index, (section_start, file_name) in enumerate(section_starts): + section_end = section_starts[index + 1][0] if index + 1 < len(section_starts) else len(raw_snippets) + section = raw_snippets[section_start:section_end] + delimiter_start = section.rfind("\n=========") + if delimiter_start < 0: + continue + complete_document = section[:delimiter_start] + if complete_document and complete_document in fitted_snippets: + fitted_docs_files.add(file_name) + return fitted_docs_files def parse_args(self, args): if args and len(args) > 0: @@ -242,6 +276,31 @@ def format_docs_url(self, file_name: str, header: str) -> str: docs_url += f"#{self.format_markdown_header(header)}" return docs_url + def _format_relevant_sources(self, relevant_sections, available_docs_files: set[str]) -> list[str]: + if not isinstance(relevant_sections, list): + return [] + + source_urls = [] + for section in relevant_sections: + if not isinstance(section, dict): + continue + + file_name = section.get("file_name") + if not isinstance(file_name, str): + continue + normalized_file_name = file_name + if normalized_file_name.startswith("/"): + normalized_file_name = normalized_file_name[1:] + if normalized_file_name not in available_docs_files: + get_logger().warning(f"Skipping a /help citation to a document that was not loaded: {file_name}") + continue + + header = section.get("relevant_section_header_string", "") + if not isinstance(header, str): + header = "" + source_urls.append(self.format_docs_url(normalized_file_name, header)) + return source_urls + async def run(self): try: @@ -265,14 +324,22 @@ async def run(self): md_files = md_files_priority + md_files_not_priority docs_prompt = "" + available_docs_files = set() for file in md_files: try: with open(file, 'r') as f: - file_path = str(file).replace(str(docs_path), '') - docs_prompt += f"\n==file name==\n\n{file_path}\n\n==file content==\n\n{f.read().strip()}\n=========\n\n" + file_contents = f.read().strip() + relative_file_path = file.relative_to(docs_path).as_posix() + docs_prompt += ( + f"\n==file name==\n\n/{relative_file_path}\n\n" + f"==file content==\n\n{file_contents}\n=========\n\n" + ) + available_docs_files.add(relative_file_path) except Exception as e: get_logger().error(f"Error while reading the file {file}: {e}") self.vars['snippets'] = docs_prompt.strip() + self._available_docs_files = available_docs_files + self._model_visible_docs_files = set() # run the AI model response = await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR) @@ -300,14 +367,12 @@ async def run(self): # prepare the answer answer_str = "" if response_str: + source_urls = self._format_relevant_sources(relevant_sections, self._model_visible_docs_files) answer_str += f"### Question: \n{self.question_str}\n\n" answer_str += f"### Answer:\n{response_str.strip()}\n\n" - answer_str += "#### Relevant Sources:\n\n" - for section in relevant_sections: - docs_url = self.format_docs_url( - section.get('file_name'), - section['relevant_section_header_string'], - ) + if source_urls: + answer_str += "#### Relevant Sources:\n\n" + for docs_url in source_urls: answer_str += f"> - {docs_url}\n" diff --git a/tests/unittest/test_pr_help_message_fallback.py b/tests/unittest/test_pr_help_message_fallback.py index c6bb6ec1ed..95647eb816 100644 --- a/tests/unittest/test_pr_help_message_fallback.py +++ b/tests/unittest/test_pr_help_message_fallback.py @@ -147,6 +147,117 @@ def model_token_count(*, model, messages): assert details.model_used == BACKUP +@pytest.mark.asyncio +async def test_fallback_only_publishes_sources_from_complete_fitted_documents(help_tool, monkeypatch): + tool, details, _ = help_tool + hidden_doc = tool._fixture_doc.parents[1] / "hidden.md" + hidden_doc.write_text("# Hidden\n\n" + "H" * 600, encoding="utf-8") + review_content = tool._fixture_doc.read_text(encoding="utf-8").strip() + review_section = ( + "==file name==\n\n/tools/review.md\n\n==file content==\n\n" + f"{review_content}\n=========" + ) + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + limits = { + PRIMARY: 10_000, + BACKUP: len(review_section) + len(pr_help_message.TRUNCATION_MARKER) + 1, + } + monkeypatch.setattr(tool, "_get_prompt_budget", lambda model: limits[model]) + monkeypatch.setattr(pr_help_message, "token_counter", count_message_characters) + tool.ai_handler.chat_completion.side_effect = [ + RuntimeError("primary unavailable"), + ( + "response: Use the documented review settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: /hidden.md\n" + " relevant_section_header_string: Hidden\n", + "stop", + ), + ] + + await tool.run() + + assert attempted_models(tool) == [PRIMARY, BACKUP] + primary_prompt = tool.ai_handler.chat_completion.await_args_list[0].kwargs["user"] + backup_prompt = tool.ai_handler.chat_completion.await_args_list[1].kwargs["user"] + assert "/hidden.md" in primary_prompt + assert review_section in backup_prompt + assert "H" * 600 not in backup_prompt + assert pr_help_message.TRUNCATION_MARKER in backup_prompt + assert tool._model_visible_docs_files == {"tools/review.md"} + published_comment = tool.git_provider.publish_comment.call_args.args[0] + assert "> - https://docs.pr-agent.ai/tools/review/#automatic-review" in published_comment + assert "https://docs.pr-agent.ai/hidden/" not in published_comment + assert published_comment.count("> - ") == 1 + assert details.model_used == BACKUP + + +@pytest.mark.parametrize("separator_prefix", ["", "\n===="], ids=["before-separator", "inside-separator"]) +def test_fitted_docs_files_accepts_complete_content_without_full_separator(separator_prefix): + visible_document = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nAll visible content" + ) + hidden_document = ( + "==file name==\n\n/hidden.md\n\n==file content==\n\n" + "# Hidden\n\nHidden content" + ) + raw_snippets = f"{visible_document}\n=========\n\n\n{hidden_document}\n=========" + fitted_snippets = f"{visible_document}{separator_prefix}{pr_help_message.TRUNCATION_MARKER}" + + assert PRHelpMessage._get_fitted_docs_files( + raw_snippets, + fitted_snippets, + {"visible.md", "hidden.md"}, + ) == {"visible.md"} + + +def test_fitted_docs_files_rejects_document_without_framing_separator(): + document = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nAll visible content" + ) + + assert PRHelpMessage._get_fitted_docs_files( + document, + document, + {"visible.md"}, + ) == set() + + +def test_fitted_docs_files_uses_final_separator_after_delimiter_like_content(): + document = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nExample output:\n=========\nStill part of the document" + ) + raw_snippets = f"{document}\n=========" + fitted_snippets = f"{document}{pr_help_message.TRUNCATION_MARKER}" + + assert PRHelpMessage._get_fitted_docs_files( + raw_snippets, + fitted_snippets, + {"visible.md"}, + ) == {"visible.md"} + + +def test_fitted_docs_files_rejects_content_clipped_after_internal_delimiter(): + content_before_delimiter = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nExample output:\n=========" + ) + raw_snippets = f"{content_before_delimiter}\nStill part of the document\n=========" + fitted_snippets = f"{content_before_delimiter}{pr_help_message.TRUNCATION_MARKER}" + + assert PRHelpMessage._get_fitted_docs_files( + raw_snippets, + fitted_snippets, + {"visible.md"}, + ) == set() + + @pytest.mark.parametrize( ("configured_output", "expected_budget"), [ @@ -360,14 +471,14 @@ def discontinuous_count(*, messages, **_kwargs): monkeypatch.setattr(pr_help_message, "token_counter", discontinuous_count) - _, user_prompt = tool._fit_prompts({"question": "q", "snippets": "ABC"}, PRIMARY) + _, user_prompt, fitted_snippets = tool._fit_prompts({"question": "q", "snippets": "ABC"}, PRIMARY) assert user_prompt == f"A{pr_help_message.TRUNCATION_MARKER}" + assert fitted_snippets == f"A{pr_help_message.TRUNCATION_MARKER}" async def test_marker_only_prompt_is_sent_when_no_document_character_fits(help_tool, monkeypatch): tool, _, _ = help_tool - tool.vars = {"question": "q", "snippets": "ABC"} get_settings().set("pr_help_prompts.system", "") get_settings().set("pr_help_prompts.user", "{{ snippets }}") monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 10) @@ -383,10 +494,15 @@ def marker_only_count(*, messages, **_kwargs): monkeypatch.setattr(pr_help_message, "token_counter", marker_only_count) tool.ai_handler.chat_completion.return_value = ANSWER, "stop" - await tool._prepare_prediction(PRIMARY) + await tool.run() tool.ai_handler.chat_completion.assert_awaited_once() assert tool.ai_handler.chat_completion.await_args.kwargs["user"] == pr_help_message.TRUNCATION_MARKER + assert tool._model_visible_docs_files == set() + published_comment = tool.git_provider.publish_comment.call_args.args[0] + assert "### Answer:\nEnable automatic review" in published_comment + assert "Relevant Sources" not in published_comment + assert "https://docs.pr-agent.ai" not in published_comment async def test_fixed_prompt_overhead_skips_model_call_and_tries_larger_fallback(help_tool, monkeypatch): diff --git a/tests/unittest/test_pr_help_message_rendering.py b/tests/unittest/test_pr_help_message_rendering.py index d00d1e2fd5..36464b7b38 100644 --- a/tests/unittest/test_pr_help_message_rendering.py +++ b/tests/unittest/test_pr_help_message_rendering.py @@ -152,6 +152,107 @@ async def test_question_uses_configured_handler_error_path_without_openai_key( assert tool.git_provider.published == [] +@pytest.mark.asyncio +async def test_question_skips_malformed_sources_without_discarding_answer( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: tools/review.md\n" + " relevant_section_header_string:\n" + " - relevant_section_header_string: Missing file name\n" + " - not a mapping\n" + " - file_name: ../../review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: https://example.com/review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: /tools/review.md?draft=true\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: ' /tools/review.md '\n" + " relevant_section_header_string: Automatic review\n" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert len(tool.git_provider.published) == 1 + comment = tool.git_provider.published[0] + assert "Enable automatic review in the repository settings." in comment + assert f"> - {CURRENT_DOCS_URL}/tools/review/#automatic-review\n" in comment + assert f"> - {CURRENT_DOCS_URL}/tools/review/\n" in comment + assert comment.count("> - ") == 2 + assert "example.com" not in comment + assert "draft=true" not in comment + + +@pytest.mark.parametrize( + "relevant_sections", + [ + " - relevant_section_header_string: Missing file name\n", + " file_name: /tools/review.md\n relevant_section_header_string: Automatic review\n", + ], + ids=["all-invalid-rows", "non-list-value"], +) +@pytest.mark.asyncio +async def test_question_preserves_answer_when_no_valid_sources_remain( + published_output, non_openai_question_settings, tmp_path, monkeypatch, relevant_sections +): + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + f"{relevant_sections}" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert len(tool.git_provider.published) == 1 + comment = tool.git_provider.published[0] + assert "Enable automatic review in the repository settings." in comment + assert "Relevant Sources" not in comment + assert CURRENT_DOCS_URL not in comment + + +@pytest.mark.asyncio +async def test_question_does_not_link_to_document_that_failed_to_load( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + unreadable_doc = tmp_path / "package" / "docs" / "docs" / "faq.md" + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /faq.md\n" + " relevant_section_header_string: FAQ\n" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + unreadable_doc.write_text("# FAQ", encoding="utf-8") + real_open = open + + def fail_unreadable_doc(file, *args, **kwargs): + if str(file) == str(unreadable_doc): + raise OSError("unreadable test document") + return real_open(file, *args, **kwargs) + + monkeypatch.setattr(pr_help_message_module, "open", fail_unreadable_doc, raising=False) + + await tool.run() + + assert len(tool.git_provider.published) == 1 + comment = tool.git_provider.published[0] + assert "Enable automatic review in the repository settings." in comment + assert "Relevant Sources" not in comment + assert f"{CURRENT_DOCS_URL}/faq/" not in comment + + @pytest.mark.parametrize( "capabilities, expected, unexpected", [ From 9a8fa9d87ca88832f6d60f80129561ead4a24c11 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:48:50 +0100 Subject: [PATCH 112/238] chore(ci): bump the actions-minor-and-patch group with 3 updates (#3382) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-and-test.yaml | 2 +- .github/workflows/codeql.yml | 4 ++-- .github/workflows/pre-commit.yml | 2 +- .github/workflows/provider-smoke.yaml | 2 +- .github/workflows/publish.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 4f54e6898e..254bbd8e4e 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Check uv.lock run: uv lock --check diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dec3c7bf80..7d1b57daec 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,12 +33,12 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} queries: security-extended,security-and-quality - - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 0cafc1f026..72a3dd19f4 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -14,6 +14,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 # SEE https://github.com/pre-commit/action - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.github/workflows/provider-smoke.yaml b/.github/workflows/provider-smoke.yaml index a4a5546132..862742d8cd 100644 --- a/.github/workflows/provider-smoke.yaml +++ b/.github/workflows/provider-smoke.yaml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Install dependencies run: uv sync diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7e6bd0d7b9..2b14c3bcf7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -255,7 +255,7 @@ jobs: with: python-version: '3.12' - - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Flag missing PyPI publish if: needs.publish-pypi.result != 'success' From d611e54a4600ccef3c67eb3c75f6bdf7aa274ca8 Mon Sep 17 00:00:00 2001 From: Vladimir Roshchin <51440383+even-even@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:18:51 +0300 Subject: [PATCH 113/238] chore: unskip F841 ruff rule (#3403) Co-authored-by: even-even <> --- pr_agent/algo/pr_processing.py | 5 ++-- pr_agent/algo/utils.py | 2 +- pr_agent/git_providers/bitbucket_provider.py | 6 ++--- .../bitbucket_server_provider.py | 2 +- pr_agent/git_providers/git_provider.py | 2 +- pr_agent/git_providers/github_provider.py | 8 +++---- pr_agent/git_providers/gitlab_provider.py | 12 ++++------ pr_agent/servers/github_app.py | 1 - pr_agent/servers/github_polling.py | 4 ++-- pr_agent/tools/pr_code_suggestions.py | 4 ++-- pr_agent/tools/pr_description.py | 2 +- pr_agent/tools/pr_help_docs.py | 24 +++++++++---------- pr_agent/tools/pr_similar_issue.py | 4 ++-- pyproject.toml | 1 - tests/e2e_tests/test_bitbucket_app.py | 2 +- tests/unittest/test_try_fix_yaml.py | 11 +-------- 16 files changed, 37 insertions(+), 53 deletions(-) diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index a89c882656..945e0f75f5 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -93,7 +93,7 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, if pr_languages: try: get_logger().info(f"PR main language: {pr_languages[0]['language']}") - except Exception as e: + except Exception: pass # generate a standard diff string, with patch extension @@ -203,7 +203,7 @@ def get_pr_diff_multiple_patchs(git_provider: GitProvider, token_handler: TokenH if pr_languages: try: get_logger().info(f"PR main language: {pr_languages[0]['language']}") - except Exception as e: + except Exception: pass patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list = \ @@ -440,7 +440,6 @@ def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_mod continue patch = data['patch'] - edit_type = data['edit_type'] # Hard Stop, no more tokens if total_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD: diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index abf89703ac..3227a756d2 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -909,7 +909,7 @@ def load_large_diff(filename, new_file_content_str: str, original_file_content_s if get_verbosity_level() >= 2 and show_warning: get_logger().info(f"File was modified, but no patch was found. Manually creating patch: {filename}.") return to_hunk_only_patch(''.join(diff)) - except Exception as e: + except Exception: get_logger().exception(f"Failed to generate patch for file: {filename}") return "" diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index cbc301ecb3..8f61e6debb 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -131,12 +131,12 @@ def get_git_repo_url(self, pr_url: str=None) -> str: #bitbucket does not support try: parsed_url = urlparse(self.pr_url) return f"{parsed_url.scheme}://{parsed_url.netloc}/{self.workspace_slug}/{self.repo_slug}.git" - except Exception as e: + except Exception: get_logger().exception(f"url is not a valid merge requests url: {self.pr_url}") return "" # Given a git repo url, return prefix and suffix of the provider in order to view a given file belonging to that repo. - # Example: git clone git clone https://bitbucket.org/pragent/pr-agent.git and branch: main -> prefix: "https://bitbucket.org/pragent/pr-agent/src/main", suffix: "" + # Example: git clone https://bitbucket.org/pragent/pr-agent.git and branch: main -> prefix: "https://bitbucket.org/pragent/pr-agent/src/main", suffix: "" # In case git url is not provided, provider will use PR context (which includes branch) to determine the prefix and suffix. def get_canonical_url_parts(self, repo_git_url:str=None, desired_branch:str=None) -> Tuple[str, str]: scheme_and_netloc = None @@ -263,7 +263,7 @@ def get_diff_files(self) -> list[FilePatchInfo]: 'names_filtered': names_filtered }) - except Exception as e: + except Exception: pass # get the pr patches diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index 2c379a7fc0..df3a4b6364 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -72,7 +72,7 @@ def get_git_repo_url(self, pr_url: str=None) -> str: #bitbucket server does not try: parsed_url = urlparse(self.pr_url) return f"{parsed_url.scheme}://{parsed_url.netloc}/scm/{self.workspace_slug.lower()}/{self.repo_slug.lower()}.git" - except Exception as e: + except Exception: get_logger().exception(f"url is not a valid merge requests url: {self.pr_url}") return "" diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 00ab7f8b1f..e3fd6ef38b 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -899,7 +899,7 @@ def calc_pr_statistics(self, pull_request_data: dict): def get_num_of_files(self): try: return len(self.get_diff_files()) - except Exception as e: + except Exception: return -1 def limit_output_characters(self, output: str, max_chars: int): diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 1aa82abdb2..eb04a3d7d2 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -107,7 +107,7 @@ def _get_issue_handle(self, issue_url) -> Optional[Issue]: return None # else: Valid repo handle: return repo_obj.get_issue(issue_number) - except Exception as e: + except Exception: get_logger().exception(f"Failed to get an issue object for issue: {issue_url}, belonging to owner/repo: {repo_name}") return None @@ -159,7 +159,7 @@ def _get_owner_and_repo_path(self, given_url: str) -> str: get_logger().error(f"url is neither an issues url nor a PR url nor a valid git url: {given_url}. Returning empty result.") return "" return repo_path - except Exception as e: + except Exception: get_logger().exception(f"unable to parse url: {given_url}. Returning empty result.") return "" @@ -295,7 +295,7 @@ def get_num_of_files(self): else: try: return len(self.git_files) - except Exception as e: + except Exception: return -1 def get_diff_files(self) -> list[FilePatchInfo]: @@ -1182,7 +1182,7 @@ def get_user_id(self): if not self.github_user_id: try: self.github_user_id = self.github_client.get_user().raw_data['login'] - except Exception as e: + except Exception: self.github_user_id = "" # logging.exception(f"Failed to get user id, error: {e}") return self.github_user_id diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 34b4d229c2..fafbb74630 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -513,7 +513,7 @@ def get_canonical_url_parts(self, repo_git_url:str=None, desired_branch:str=None if not repo_git_url: #Use PR url as context try: desired_branch = self.gl.projects.get(self.id_project).default_branch - except Exception as e: + except Exception: get_logger().exception(f"Cannot get PR: {self.pr_url} default branch. Tried project ID: {self.id_project}") return ("", "") # numeric-alias URLs need the "projects/" segment, same as get_line_link @@ -878,7 +878,7 @@ def get_diff_files(self) -> list[FilePatchInfo]: 'original_files': names_original, 'filtered_files': names_filtered }) - except Exception as e: + except Exception: pass diff_files = [] @@ -1212,7 +1212,7 @@ def _create_suggestion_note(self, as_draft: bool, body: str, pos_obj: dict, diff store.add(body_fp) store.add(code_fp) return True - except Exception as e: + except Exception: try: # fallback - create a general note on the file in the MR if 'suggestion_orig_location' in original_suggestion: @@ -1235,10 +1235,6 @@ def _create_suggestion_note(self, as_draft: bool, body: str, pos_obj: dict, diff label = original_suggestion['label'] score = original_suggestion.get('score', 7) - if hasattr(self, 'main_language'): - language = self.main_language - else: - language = '' link = self.get_line_link(relevant_file, line_start, line_end) body_fallback =f"**Suggestion:** {content} [{label}, importance: {score}]\n\n" body_fallback +=f"\n\n
    [{target_file.filename} [{line_start}-{line_end}]]({link}):\n\n" @@ -1272,7 +1268,7 @@ def _create_suggestion_note(self, as_draft: bool, body: str, pos_obj: dict, diff store.add(code_fp) return True - except Exception as e: + except Exception: get_logger().exception(f"Failed to create comment in MR {self.id_mr}") return False diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index fc05107fe8..2e2e496db2 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -164,7 +164,6 @@ async def handle_new_pr_opened(body: Dict[str, Any], action: str, log_context: Dict[str, Any], agent: PRAgent): - title = body.get("pull_request", {}).get("title", "") pull_request, api_url = _check_pull_request_event(action, body, log_context) if not (pull_request and api_url): diff --git a/pr_agent/servers/github_polling.py b/pr_agent/servers/github_polling.py index d65b3d5c65..8b83c13acc 100644 --- a/pr_agent/servers/github_polling.py +++ b/pr_agent/servers/github_polling.py @@ -115,7 +115,7 @@ def process_comment_sync(pr_url, rest_of_comment, comment_id): try: # Run the async handle_request in a separate function git_provider = get_git_provider()(pr_url=pr_url) - success = run_handle_request(pr_url, rest_of_comment, comment_id, git_provider) + run_handle_request(pr_url, rest_of_comment, comment_id, git_provider) except Exception as e: get_logger().error(f"Error processing comment: {e}", artifact={"traceback": traceback.format_exc()}) @@ -125,7 +125,7 @@ async def process_comment(pr_url, rest_of_comment, comment_id): git_provider = get_git_provider()(pr_url=pr_url) git_provider.set_pr(pr_url) agent = PRAgent() - success = await agent.handle_request( + await agent.handle_request( pr_url, rest_of_comment, notify=lambda: git_provider.add_eyes_reaction(comment_id) diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index ed068f9101..88ece7c540 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -949,7 +949,7 @@ async def analyze_self_reflection_response(self, data, response_reflect): get_logger().error(f"Failed to log suggestion statistics, error: {e}") pass - except Exception as e: # + except Exception: get_logger().error(f"Error processing suggestion score {i}", artifact={"suggestion": suggestion, "code_suggestions_feedback": code_suggestions_feedback[i]}) @@ -1869,7 +1869,7 @@ async def convert_to_decoupled_with_line_numbers(self, patches_diff_list_no_line patch_final = clip_tokens(patch_final, max_tokens_full - delta_output) patches_diff_list.append(patch_final) return patches_diff_list - except Exception as e: + except Exception: get_logger().exception("Error converting to decoupled with line numbers", artifact={'patches_diff_list_no_line_numbers': patches_diff_list_no_line_numbers}) return [] diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 971fb5de80..de50f296c8 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -780,7 +780,7 @@ def _prepare_file_labels(self): if label not in file_label_dict: file_label_dict[label] = [] file_label_dict[label].append((filename, changes_title, changes_summary)) - except Exception as e: + except Exception: get_logger().exception(f"Error preparing file label dict {self.pr_id}") pass return file_label_dict diff --git a/pr_agent/tools/pr_help_docs.py b/pr_agent/tools/pr_help_docs.py index 5f7afbba14..c6d04dde0c 100644 --- a/pr_agent/tools/pr_help_docs.py +++ b/pr_agent/tools/pr_help_docs.py @@ -114,7 +114,7 @@ def return_document_headings(text: str, ext: str) -> str: return "" return '\n'.join(headings) - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -140,7 +140,7 @@ def map_documentation_files_to_contents(base_path: str, doc_files: list[str], ma if not returned_dict: get_logger().error("Couldn't find any usable documentation files. Returning empty dict.") return returned_dict - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty dict.") return {} @@ -164,7 +164,7 @@ def aggregate_documentation_files_for_prompt_contents(file_path_to_contents: dic else: docs_prompt += f"\n==file name==\n\n{file_path}\n\n==file content==\n\n{file_contents}\n=========\n\n" return docs_prompt - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -205,7 +205,7 @@ def format_markdown_q_and_a_response(question_str: str, response_str: str, relev answer_str += "#### Relevant Sources:\n\n" answer_str += ''.join(source_links) return answer_str - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -293,7 +293,7 @@ def clean_markdown_content(content: str) -> str: content = re.sub(r'<(?!table|tr|td|th|thead|tbody)([a-zA-Z][a-zA-Z0-9]*)[^>]*>(.*?)', r'\2', content, flags=re.DOTALL) return content.strip() - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -305,7 +305,7 @@ def __init__(self, ai_handler, vars, system_prompt, user_prompt): environment = Environment(undefined=StrictUndefined) self.system_prompt = environment.from_string(system_prompt).render(variables) self.user_prompt = environment.from_string(user_prompt).render(variables) - except Exception as e: + except Exception: get_logger().exception("Caught exception during init. Setting ai_handler to None to prevent __call__.") self.ai_handler = None @@ -363,7 +363,7 @@ def __init__(self, ctx_url, ai_handler:partial[BaseAiHandler,] = LiteLLMAIHandle self.vars, get_settings().pr_help_docs_prompts.system, get_settings().pr_help_docs_prompts.user) - except Exception as e: + except Exception: get_logger().exception("Caught exception during init. Setting self.question to None to prevent run() to do anything.") self.question = None @@ -433,7 +433,7 @@ async def run(self): else: get_logger().info("Answer:", artifacts={'answer_str': answer_str}) return answer_str - except Exception as e: + except Exception: get_logger().exception('failed to provide answer to given user question as a result of a thrown exception (see above)') def _find_all_document_files_matching_exts(self, abs_docs_path: str, ignore_readme=False, max_allowed_files=5000) -> list[str]: @@ -457,7 +457,7 @@ def _find_all_document_files_matching_exts(self, abs_docs_path: str, ignore_read get_logger().warning(f"Found at least {max_allowed_files} files in {abs_docs_path}, skipping the rest.") return matching_files return matching_files - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty list.") return [] @@ -525,7 +525,7 @@ def _gen_filenames_to_contents_map_from_repo(self) -> dict[str, str]: artifacts={'doc_files': doc_files}) return map_documentation_files_to_contents(returned_cloned_repo_root.path, doc_files) - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty dict.") return {} @@ -601,7 +601,7 @@ async def _rank_docs_and_return_them_as_prompt(self, docs_filepath_to_contents: get_logger().error("_trim_docs_input returned an empty result.") return "" return docs_prompt_to_send_to_model - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -627,6 +627,6 @@ def _format_model_answer(self, response_str: str, relevant_sections: list[dict[s answer_str += HelpMessage.get_help_docs_usage_guide() answer_str += "\n
    \n" return answer_str - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index 1b8629c63d..ed7563cbf3 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -444,9 +444,9 @@ async def run(self): url = list(issue.get_comments())[relevant_comment_number_list[i]].html_url similar_issues_str += f"{i + 1}. **[{title}]({url})** (score={score_list[i]})\n\n" if get_settings().config.publish_output: - response = issue_main.create_comment(similar_issues_str) + issue_main.create_comment(similar_issues_str) get_logger().info(similar_issues_str) - get_logger().info('Done') + get_logger().info("Done") def _process_issue(self, issue): header = issue.title diff --git a/pyproject.toml b/pyproject.toml index 3ae597d9c2..964de7837d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,7 +172,6 @@ lint.ignore = [ "E402", # import not at top of file "E501", # line too long "E722", # bare `except` - "F841", # unused local variable "B011", # `assert False` "B017", # assertRaises(Exception) "B023", # function definition uses loop variable diff --git a/tests/e2e_tests/test_bitbucket_app.py b/tests/e2e_tests/test_bitbucket_app.py index 26b5fa3f50..cb5181c35e 100644 --- a/tests/e2e_tests/test_bitbucket_app.py +++ b/tests/e2e_tests/test_bitbucket_app.py @@ -37,7 +37,7 @@ def test_e2e_run_bitbucket_app(): # Create a new branch from the base branch logger.info(f"Creating a new branch {new_branch} from {base_branch}") source_branch = repo.branches.get(base_branch) - target_repo = repo.branches.create(new_branch,source_branch.hash) + repo.branches.create(new_branch,source_branch.hash) # Update the file content url = f"https://api.bitbucket.org/2.0/repositories/{project_key}/{repo_slug}/src" diff --git a/tests/unittest/test_try_fix_yaml.py b/tests/unittest/test_try_fix_yaml.py index 339889c63d..35f2dc80d8 100644 --- a/tests/unittest/test_try_fix_yaml.py +++ b/tests/unittest/test_try_fix_yaml.py @@ -409,22 +409,13 @@ def test_wrong_indentation_code_block_scalar(self): int sub(int a, int b) { return a - b; } -''' - expected_code_block = '''\ -int sum(int a, int b) { - return a + b; -} - -int sub(int a, int b) { - return a - b; -} ''' expected_output = {'code_suggestions': [{'relevant_file': 'a.c\n', 'existing_code': ' int sum(int a, int b) {\n return a + b;\n }\n\n int sub(int a, int b) {\n return a - b;\n }\n'}]} assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='existing_code') == expected_output def test_diff_markers_removed_within_list_item(self): """ - Ensures diff-style '-' markers nested inside list items are normalised so the YAML parses + Ensures diff-style '-' markers nested inside list items are normalized so the YAML parses into the expected structure. """ review_text = '''\ From 76bc34d399d462bfa5ee88e815fda0c3a502f05a Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:56:07 +0545 Subject: [PATCH 114/238] fix(security): keep Bitbucket Server bearer tokens out of argv (#3391) --- .../bitbucket_server_provider.py | 20 ++++++++++------- ...test_bitbucket_server_clone_credentials.py | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 tests/unittest/test_bitbucket_server_clone_credentials.py diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index df3a4b6364..5e71362494 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -1,6 +1,5 @@ import difflib import re -import shlex import subprocess from collections import Counter from types import SimpleNamespace @@ -763,10 +762,15 @@ def _clone_inner(self, repo_url: str, dest_folder: str, operation_timeout_in_sec #Shouldn't happen since this is checked in _prepare_clone, therefore - throwing an exception. raise RuntimeError("Bearer token is required!") - cli_args = shlex.split(f"git clone -c http.extraHeader='Authorization: Bearer {bearer_token}' " - f"--filter=blob:none --depth 1 {repo_url} {dest_folder}") - - ssl_env = get_git_ssl_env() - - subprocess.run(cli_args, env=ssl_env, check=True, # check=True will raise an exception if the command fails - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=operation_timeout_in_seconds) + # Pass the header through the Git process environment (Git >= 2.31), so it never + # reaches argv or the checkout's .git/config. + ssl_env = { + **get_git_ssl_env(), + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraHeader", + "GIT_CONFIG_VALUE_0": f"Authorization: Bearer {bearer_token}", + } + cli_args = ["git", "clone", "--filter=blob:none", "--depth", "1", repo_url, dest_folder] + subprocess.run(cli_args, env=ssl_env, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=operation_timeout_in_seconds) diff --git a/tests/unittest/test_bitbucket_server_clone_credentials.py b/tests/unittest/test_bitbucket_server_clone_credentials.py new file mode 100644 index 0000000000..ebd924c38d --- /dev/null +++ b/tests/unittest/test_bitbucket_server_clone_credentials.py @@ -0,0 +1,22 @@ +from unittest.mock import patch + +from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider + + +def test_clone_keeps_bearer_token_out_of_process_arguments(tmp_path): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider.bearer_token = "secret bearer token" + captured = {} + + def fake_run(args, env, **kwargs): + captured["args"] = args + captured["env"] = env + + with patch("pr_agent.git_providers.bitbucket_server_provider.get_git_ssl_env", return_value={}), \ + patch("pr_agent.git_providers.bitbucket_server_provider.subprocess.run", side_effect=fake_run): + provider._clone_inner("https://bitbucket.example/scm/team/repo.git", str(tmp_path / "repo")) + + assert "secret bearer token" not in " ".join(captured["args"]) + assert captured["env"]["GIT_CONFIG_COUNT"] == "1" + assert captured["env"]["GIT_CONFIG_KEY_0"] == "http.extraHeader" + assert captured["env"]["GIT_CONFIG_VALUE_0"] == "Authorization: Bearer secret bearer token" From 2f362e80b60f7cfd6a974b06ee1c47269c57ebf0 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Tue, 15 Sep 2026 14:11:18 -0500 Subject: [PATCH 115/238] fix: package help documentation corpus (#3351) Co-authored-by: IsmaelMartinez Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- .github/workflows/build-and-test.yaml | 3 + MANIFEST.in | 1 + docker/Dockerfile.lambda | 8 + pr_agent/tools/pr_help_message.py | 159 ++++++-- setup.py | 49 +++ tests/packaging/test_help_docs_package.py | 338 ++++++++++++++++ .../test_pr_help_message_rendering.py | 12 +- .../test_pr_help_message_resources.py | 383 ++++++++++++++++++ 8 files changed, 913 insertions(+), 40 deletions(-) create mode 100644 setup.py create mode 100644 tests/packaging/test_help_docs_package.py create mode 100644 tests/unittest/test_pr_help_message_resources.py diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 254bbd8e4e..7c22d3fea6 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -22,6 +22,9 @@ jobs: - name: Check uv.lock run: uv lock --check + - name: Test package artifacts + run: uv run pytest -q tests/packaging/test_help_docs_package.py + - id: dockerx name: Setup Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 diff --git a/MANIFEST.in b/MANIFEST.in index 194b3def92..bda73ce4a4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ recursive-include pr_agent *.toml +recursive-include docs/docs *.md recursive-exclude pr_agent *.secrets.toml diff --git a/docker/Dockerfile.lambda b/docker/Dockerfile.lambda index 112c2de12b..d98dfbb2ac 100644 --- a/docker/Dockerfile.lambda +++ b/docker/Dockerfile.lambda @@ -13,6 +13,14 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project --no-dev --extra lambda --python /var/lang/bin/python3.12 ENV PYTHONPATH="/opt/venv/lib/python3.12/site-packages:${LAMBDA_TASK_ROOT}" COPY pr_agent/ ${LAMBDA_TASK_ROOT}/pr_agent/ +RUN --mount=type=bind,source=docs/docs,target=/tmp/help-docs-source \ + source_count="$(find /tmp/help-docs-source -type f -name '*.md' | wc -l)" && \ + test "$source_count" -gt 0 && \ + cd /tmp/help-docs-source && \ + find . -type f -name '*.md' -exec sh -c 'for source in "$@"; do destination="${LAMBDA_TASK_ROOT}/pr_agent/_help_docs/${source#./}"; mkdir -p "$(dirname "$destination")"; cp "$source" "$destination"; done' sh {} + && \ + destination_count="$(find "${LAMBDA_TASK_ROOT}/pr_agent/_help_docs" -type f | wc -l)" && \ + test "$destination_count" -eq "$source_count" && \ + test -z "$(find "${LAMBDA_TASK_ROOT}/pr_agent/_help_docs" -type f ! -name '*.md' -print -quit)" FROM base AS github_lambda CMD ["pr_agent.servers.github_lambda_webhook.lambda_handler"] diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index 96c10adfad..26026d46c2 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -1,8 +1,10 @@ import copy import re from functools import partial +from importlib.resources import files as package_files +from importlib.resources.abc import Traversable from math import ceil, isfinite -from pathlib import Path +from pathlib import Path, PurePosixPath from jinja2 import Environment, StrictUndefined, select_autoescape from litellm import token_counter @@ -22,6 +24,114 @@ MESSAGE_FRAMING_TOKEN_ALLOWANCE = 16 REPLY_FRAMING_TOKEN_ALLOWANCE = 16 TRUNCATION_MARKER = "\n...(truncated)\n" +HELP_DOCS_RESOURCE = "_help_docs" +HELP_DOCS_UNAVAILABLE_MESSAGE = ( + "PR-Agent help documentation is unavailable. Please contact the service administrator." +) +HELP_DOCS_EXCLUDED_DIRECTORIES = {"finetuning_benchmark"} +HELP_DOCS_EXCLUDED_FILENAMES = {"compression_strategy.md"} +HELP_DOCS_PRIORITY_PATHS = ( + "/usage-guide/", + "/tools/describe.md", + "/tools/review.md", + "/tools/improve.md", + "/faq/", +) + + +def _is_help_doc_included(relative_path: PurePosixPath) -> bool: + return ( + relative_path.suffix == ".md" + and relative_path.name not in HELP_DOCS_EXCLUDED_FILENAMES + and not any(part in HELP_DOCS_EXCLUDED_DIRECTORIES for part in relative_path.parts[:-1]) + ) + + +def _get_help_docs_root() -> Traversable: + try: + packaged_docs = package_files("pr_agent").joinpath(HELP_DOCS_RESOURCE) + if packaged_docs.is_dir(): + return packaged_docs + except Exception: + get_logger().opt(exception=True).debug("Unable to inspect packaged /help documentation") + + try: + source_docs = Path(__file__).resolve().parents[2] / "docs" / "docs" + if source_docs.is_dir(): + return source_docs + except Exception as error: + get_logger().opt(exception=True).error("Unable to inspect source-tree /help documentation") + raise FileNotFoundError( + "The /help documentation corpus is unavailable; unable to inspect the source checkout" + ) from error + + raise FileNotFoundError( + "The /help documentation corpus is unavailable; reinstall PR-Agent from a complete package" + ) + + +def _iter_help_docs(root: Traversable) -> list[tuple[PurePosixPath, Traversable]]: + documents: list[tuple[PurePosixPath, Traversable]] = [] + + def visit(directory: Traversable, relative_directory: PurePosixPath) -> None: + try: + children = sorted(directory.iterdir(), key=lambda child: child.name) + except Exception: + get_logger().opt(exception=True).error( + f"Error while listing the documentation directory {directory}" + ) + return + + for child in children: + relative_path = relative_directory / child.name + try: + if child.is_dir(): + if child.name not in HELP_DOCS_EXCLUDED_DIRECTORIES: + visit(child, relative_path) + elif child.is_file() and _is_help_doc_included(relative_path): + documents.append((relative_path, child)) + except Exception: + get_logger().opt(exception=True).error( + f"Error while inspecting the documentation path {child}" + ) + + visit(root, PurePosixPath()) + + def sort_key(document: tuple[PurePosixPath, Traversable]) -> tuple[int, str]: + relative_name = f"/{document[0].as_posix()}" + priority_rank = 0 if relative_name == "/index.md" else 1 + if priority_rank and not any(priority in relative_name for priority in HELP_DOCS_PRIORITY_PATHS): + priority_rank = 2 + return priority_rank, relative_name + + return sorted(documents, key=sort_key) + + +def _load_help_docs_prompt() -> tuple[str, set[str]]: + docs_root = _get_help_docs_root() + sections = [] + available_docs_files = set() + for relative_path, document in _iter_help_docs(docs_root): + try: + content = document.read_text(encoding="utf-8").strip() + except Exception: + get_logger().opt(exception=True).error( + f"Error while reading the documentation file {document}" + ) + continue + if not content: + continue + relative_file_path = relative_path.as_posix() + sections.append( + f"\n==file name==\n\n/{relative_file_path}\n\n" + f"==file content==\n\n{content}\n=========\n\n" + ) + available_docs_files.add(relative_file_path) + + if not sections: + raise FileNotFoundError(f"No readable Markdown documentation was found for /help in {docs_root}") + + return "".join(sections).strip(), available_docs_files class PRHelpMessage: @@ -303,44 +413,23 @@ def _format_relevant_sources(self, relevant_sections, available_docs_files: set[ async def run(self): + if self.question_str: + try: + self.vars["snippets"], self._available_docs_files = _load_help_docs_prompt() + self._model_visible_docs_files = set() + except FileNotFoundError: + get_logger().exception("Unable to load the PR-Agent help documentation") + if get_settings().config.publish_output: + try: + self.git_provider.publish_comment(HELP_DOCS_UNAVAILABLE_MESSAGE) + except Exception: + get_logger().exception("Unable to publish the help documentation failure message") + raise + try: if self.question_str: get_logger().info(f'Answering a PR question about the PR {self.git_provider.pr_url} ') - # current path - docs_path= Path(__file__).parent.parent.parent / 'docs' / 'docs' - # get all the 'md' files inside docs_path and its subdirectories - md_files = list(docs_path.glob('**/*.md')) - folders_to_exclude = ['/finetuning_benchmark/'] - files_to_exclude = {'compression_strategy.md', '/docs/overview/index.md'} - md_files = [file for file in md_files if not any(folder in str(file) for folder in folders_to_exclude) and not any(file.name == file_to_exclude for file_to_exclude in files_to_exclude)] - - # sort the 'md_files' so that 'priority_files' will be at the top - priority_files_strings = ['/docs/index.md', '/usage-guide', 'tools/describe.md', 'tools/review.md', - 'tools/improve.md', '/faq'] - md_files_priority = [file for file in md_files if - any(priority_string in str(file) for priority_string in priority_files_strings)] - md_files_not_priority = [file for file in md_files if file not in md_files_priority] - md_files = md_files_priority + md_files_not_priority - - docs_prompt = "" - available_docs_files = set() - for file in md_files: - try: - with open(file, 'r') as f: - file_contents = f.read().strip() - relative_file_path = file.relative_to(docs_path).as_posix() - docs_prompt += ( - f"\n==file name==\n\n/{relative_file_path}\n\n" - f"==file content==\n\n{file_contents}\n=========\n\n" - ) - available_docs_files.add(relative_file_path) - except Exception as e: - get_logger().error(f"Error while reading the file {file}: {e}") - self.vars['snippets'] = docs_prompt.strip() - self._available_docs_files = available_docs_files - self._model_visible_docs_files = set() - # run the AI model response = await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR) response_yaml = load_yaml(response) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..d8ecb3c35d --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +from pathlib import Path +from shutil import rmtree + +from setuptools import setup +from setuptools.command.build_py import build_py as _build_py + +PROJECT_ROOT = Path(__file__).resolve().parent +HELP_DOCS_SOURCE = PROJECT_ROOT / "docs" / "docs" +HELP_DOCS_PACKAGE = Path("pr_agent") / "_help_docs" + + +class BuildPy(_build_py): + """Build the external help corpus as package resources.""" + + def _help_docs_output_mapping(self) -> dict[str, str]: + return { + str(Path(self.build_lib) / HELP_DOCS_PACKAGE / source.relative_to(HELP_DOCS_SOURCE)): str(source) + for source in sorted(HELP_DOCS_SOURCE.rglob("*.md")) + if source.is_file() + } + + def get_output_mapping(self) -> dict[str, str]: + mapping = super().get_output_mapping() + mapping.update(self._help_docs_output_mapping()) + return mapping + + def get_outputs(self, include_bytecode: bool = True) -> list[str]: + outputs = super().get_outputs(include_bytecode) + return list(dict.fromkeys([*outputs, *self._help_docs_output_mapping()])) + + def run(self) -> None: + super().run() + if getattr(self, "editable_mode", False): + return + + mapping = self._help_docs_output_mapping() + if not mapping: + raise FileNotFoundError(f"No Markdown help documents found under {HELP_DOCS_SOURCE}") + + destination = Path(self.build_lib) / HELP_DOCS_PACKAGE + if destination.exists() and not self.dry_run: + rmtree(destination) + + for output, source in mapping.items(): + self.mkpath(str(Path(output).parent)) + self.copy_file(source, output) + + +setup(cmdclass={"build_py": BuildPy}) diff --git a/tests/packaging/test_help_docs_package.py b/tests/packaging/test_help_docs_package.py new file mode 100644 index 0000000000..a49cdf2f83 --- /dev/null +++ b/tests/packaging/test_help_docs_package.py @@ -0,0 +1,338 @@ +"""End-to-end packaging regression for the question-mode /help corpus. + +Run explicitly because the default unit-test container does not include the +repository-level build inputs used here:: + + uv run pytest -q tests/packaging/test_help_docs_package.py +""" + +import json +import os +import shutil +import subprocess +import sys +import sysconfig +import tarfile +import tomllib +import zipfile +from pathlib import Path, PurePosixPath + +from pr_agent.tools.pr_help_message import _is_help_doc_included + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +RESOURCE_PREFIX = "pr_agent/_help_docs/" +SMOKE_SCRIPT = r""" +import asyncio +import json +import os +import site +import sys +from pathlib import Path +from types import SimpleNamespace + +blocked_source = Path(os.environ["BLOCKED_SOURCE"]).resolve() +sys.path[:] = [ + entry + for entry in sys.path + if not entry or Path(entry).resolve() != blocked_source +] +editable_target = os.environ.get("EDITABLE_TARGET") +if editable_target: + site.addsitedir(editable_target) +sys.path.append(os.environ["DEPENDENCY_PATH"]) + +from pr_agent.tools import pr_help_message +from pr_agent.tools.pr_help_message import PRHelpMessage + +calls = [] + +async def fake_retry(_prediction, *, model_type): + calls.append(model_type) + assert "==file name==" in tool.vars["snippets"] + assert "/tools/review.md" in tool.vars["snippets"] + return "response: ok\nrelevant_sections: []" + +pr_help_message.retry_with_fallback_models = fake_retry +tool = PRHelpMessage.__new__(PRHelpMessage) +tool.git_provider = SimpleNamespace(pr_url="https://example.com/org/repo/pull/1") +tool.ai_handler = SimpleNamespace() +tool.question_str = "How does review work?" +tool.return_as_string = False +tool.vars = {"question": tool.question_str, "snippets": ""} +asyncio.run(tool.run()) + +root = pr_help_message._get_help_docs_root() +documents = [path.as_posix() for path, _resource in pr_help_message._iter_help_docs(root)] +print(json.dumps({ + "module": pr_help_message.__file__, + "root": str(root), + "documents": documents, + "snippet_chars": len(tool.vars["snippets"]), + "model_calls": len(calls), +})) +""" +ZIP_RESOURCE_SCRIPT = r""" +import json +from importlib.resources import files + +root = files("pr_agent").joinpath("_help_docs") + +def walk(directory, prefix=""): + documents = [] + for child in directory.iterdir(): + relative = f"{prefix}/{child.name}" if prefix else child.name + if child.is_dir(): + documents.extend(walk(child, relative)) + elif child.is_file() and child.name.endswith(".md"): + assert child.read_text(encoding="utf-8") is not None + documents.append(relative) + return documents + +print(json.dumps({"root": str(root), "documents": sorted(walk(root))})) +""" + + +def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess: + result = subprocess.run(command, cwd=cwd, env=env, text=True, capture_output=True) + assert result.returncode == 0, ( + f"Command failed ({result.returncode}): {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +def _copy_repository(destination: Path) -> None: + shutil.copytree( + REPOSITORY_ROOT, + destination, + ignore=shutil.ignore_patterns(".git", ".venv", "build", "dist", "__pycache__", ".pytest_cache"), + ) + + +def _source_documents(repository: Path) -> set[str]: + docs_root = repository / "docs" / "docs" + return { + document.relative_to(docs_root).as_posix() + for document in docs_root.rglob("*.md") + if document.is_file() + } + + +def _sdist_documents(sdist: Path) -> set[str]: + with tarfile.open(sdist) as archive: + return { + name.split("/docs/docs/", 1)[1] + for name in archive.getnames() + if "/docs/docs/" in name and name.endswith(".md") + } + + +def _wheel_documents(wheel: Path) -> set[str]: + with zipfile.ZipFile(wheel) as archive: + return { + name.removeprefix(RESOURCE_PREFIX) + for name in archive.namelist() + if name.startswith(RESOURCE_PREFIX) and name.endswith(".md") + } + + +def _build_sdist(uv: str, repository: Path, output: Path, env: dict[str, str]) -> Path: + _run([uv, "build", "--sdist", "--out-dir", str(output), str(repository)], cwd=repository, env=env) + return next(output.glob("*.tar.gz")) + + +def _build_wheel(uv: str, repository: Path, output: Path, env: dict[str, str]) -> Path: + _run([uv, "build", "--wheel", "--out-dir", str(output), str(repository)], cwd=repository, env=env) + return next(output.glob("*.whl")) + + +def _run_smoke( + python: str, + temporary_root: Path, + *, + blocked_source: Path, + python_path: Path | None = None, + editable_target: Path | None = None, +) -> dict: + env = os.environ.copy() + env["BLOCKED_SOURCE"] = str(blocked_source) + env["DEPENDENCY_PATH"] = sysconfig.get_paths()["purelib"] + if python_path: + env["PYTHONPATH"] = str(python_path) + else: + env.pop("PYTHONPATH", None) + if editable_target: + env["EDITABLE_TARGET"] = str(editable_target) + else: + env.pop("EDITABLE_TARGET", None) + result = _run([python, "-S", "-c", SMOKE_SCRIPT], cwd=temporary_root, env=env) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _run_zip_resource_smoke(python: str, temporary_root: Path, wheel: Path) -> dict: + env = os.environ.copy() + env["PYTHONPATH"] = str(wheel) + result = _run([python, "-S", "-c", ZIP_RESOURCE_SCRIPT], cwd=temporary_root, env=env) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _install_editable( + uv: str, + repository: Path, + target: Path, + env: dict[str, str], + *, + strict: bool, +) -> None: + command = [uv, "pip", "install", "--target", str(target), "--no-deps", "--editable", str(repository)] + if strict: + command.extend(["--config-setting", "editable_mode=strict"]) + _run(command, cwd=repository, env=env) + + +def _replace_symlinks_with_hardlinks(root: Path) -> None: + for path in sorted(root.rglob("*")): + if path.is_symlink(): + source = path.resolve() + path.unlink() + os.link(source, path) + + +def test_help_docs_distribution_contract(tmp_path): + uv = shutil.which("uv") + assert uv, "uv is required for the packaging regression" + with (REPOSITORY_ROOT / "pyproject.toml").open("rb") as pyproject: + required_uv = tomllib.load(pyproject)["tool"]["uv"]["required-version"].removeprefix("==") + assert _run([uv, "--version"], cwd=tmp_path).stdout.startswith(f"uv {required_uv} ") + + build_env = os.environ.copy() + build_env["UV_CACHE_DIR"] = str(tmp_path / "uv-cache") + source_copy = tmp_path / "source" + _copy_repository(source_copy) + expected_documents = _source_documents(source_copy) + assert expected_documents + expected_runtime_documents = { + document for document in expected_documents if _is_help_doc_included(PurePosixPath(document)) + } + + sdist = _build_sdist(uv, source_copy, tmp_path / "sdist", build_env) + assert _sdist_documents(sdist) == expected_documents + + extracted = tmp_path / "extracted" + extracted.mkdir() + with tarfile.open(sdist) as archive: + archive.extractall(extracted, filter="data") + sdist_source = next(extracted.iterdir()) + wheel = _build_wheel(uv, sdist_source, tmp_path / "wheel", build_env) + assert _wheel_documents(wheel) == expected_documents + + installed = tmp_path / "installed" + _run([uv, "pip", "install", "--target", str(installed), "--no-deps", str(wheel)], cwd=tmp_path, env=build_env) + installed_result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + python_path=installed, + ) + assert Path(installed_result["module"]).is_relative_to(installed) + assert set(installed_result["documents"]) == expected_runtime_documents + assert installed_result["model_calls"] == 1 + assert installed_result["snippet_chars"] > 0 + + zip_result = _run_zip_resource_smoke(sys.executable, tmp_path, wheel) + assert ".whl/pr_agent/_help_docs" in zip_result["root"] + assert set(zip_result["documents"]) == expected_documents + + default_target = tmp_path / "editable-default" + _install_editable(uv, source_copy, default_target, build_env, strict=False) + default_result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + editable_target=default_target, + ) + assert Path(default_result["module"]).is_relative_to(source_copy) + assert Path(default_result["root"]).is_relative_to(source_copy / "docs" / "docs") + assert set(default_result["documents"]) == expected_runtime_documents + + strict_target = tmp_path / "editable-strict" + _install_editable(uv, source_copy, strict_target, build_env, strict=True) + strict_trees = list((source_copy / "build").glob("__editable__.*")) + assert len(strict_trees) == 1 + strict_tree = strict_trees[0] + strict_resource_root = strict_tree / RESOURCE_PREFIX.removesuffix("/") + assert { + document.relative_to(strict_resource_root).as_posix() + for document in strict_resource_root.rglob("*.md") + } == expected_documents + + _replace_symlinks_with_hardlinks(strict_tree) + assert not any(path.is_symlink() for path in strict_tree.rglob("*")) + strict_result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + editable_target=strict_target, + ) + assert Path(strict_result["module"]).is_relative_to(strict_tree) + assert Path(strict_result["root"]).is_relative_to(strict_resource_root) + assert set(strict_result["documents"]) == expected_runtime_documents + assert strict_result["model_calls"] == 1 + + first_direct_wheel = _build_wheel(uv, source_copy, tmp_path / "first-direct-wheel", build_env) + assert _wheel_documents(first_direct_wheel) == expected_documents + + removed_document = source_copy / "docs" / "docs" / "summary.md" + assert removed_document.exists() + removed_document.unlink() + rebuilt_wheel = _build_wheel(uv, source_copy, tmp_path / "rebuilt-wheel", build_env) + assert _wheel_documents(rebuilt_wheel) == expected_documents - {"summary.md"} + + +def test_lambda_image_materializes_only_help_markdown_in_shared_base(): + dockerfile = (REPOSITORY_ROOT / "docker" / "Dockerfile.lambda").read_text(encoding="utf-8") + + mount = "RUN --mount=type=bind,source=docs/docs,target=/tmp/help-docs-source" + assert mount in dockerfile + assert f"{mount},rw" not in dockerfile + assert f"{mount},readwrite" not in dockerfile + assert "COPY docs/docs" not in dockerfile + assert "find /tmp/help-docs-source -type f -name '*.md'" in dockerfile + assert 'test "$source_count" -gt 0' in dockerfile + assert 'test "$destination_count" -eq "$source_count"' in dockerfile + assert "-type f ! -name '*.md' -print -quit" in dockerfile + assert "FROM base AS github_lambda" in dockerfile + assert "FROM base AS gitlab_lambda" in dockerfile + + +def test_lambda_layout_loads_materialized_help_docs(tmp_path): + lambda_root = tmp_path / "lambda" + shutil.copytree(REPOSITORY_ROOT / "pr_agent", lambda_root / "pr_agent") + resource_root = lambda_root / RESOURCE_PREFIX.removesuffix("/") + expected_documents = _source_documents(REPOSITORY_ROOT) + + for relative_name in expected_documents: + source = REPOSITORY_ROOT / "docs" / "docs" / relative_name + destination = resource_root / relative_name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + materialized_documents = { + document.relative_to(resource_root).as_posix() + for document in resource_root.rglob("*") + if document.is_file() + } + assert materialized_documents == expected_documents + + result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + python_path=lambda_root, + ) + assert Path(result["module"]).is_relative_to(lambda_root) + assert Path(result["root"]).is_relative_to(resource_root) + assert set(result["documents"]) == { + document for document in expected_documents if _is_help_doc_included(PurePosixPath(document)) + } + assert result["model_calls"] == 1 diff --git a/tests/unittest/test_pr_help_message_rendering.py b/tests/unittest/test_pr_help_message_rendering.py index 36464b7b38..515288f609 100644 --- a/tests/unittest/test_pr_help_message_rendering.py +++ b/tests/unittest/test_pr_help_message_rendering.py @@ -1,5 +1,7 @@ """Regression tests for provider-independent /help behavior.""" +from pathlib import Path + import pytest from pr_agent.config_loader import get_settings @@ -235,14 +237,14 @@ async def test_question_does_not_link_to_document_that_failed_to_load( ) tool = build_question_tool(tmp_path, monkeypatch, handler) unreadable_doc.write_text("# FAQ", encoding="utf-8") - real_open = open + real_read_text = Path.read_text - def fail_unreadable_doc(file, *args, **kwargs): - if str(file) == str(unreadable_doc): + def fail_unreadable_doc(document, *args, **kwargs): + if document == unreadable_doc: raise OSError("unreadable test document") - return real_open(file, *args, **kwargs) + return real_read_text(document, *args, **kwargs) - monkeypatch.setattr(pr_help_message_module, "open", fail_unreadable_doc, raising=False) + monkeypatch.setattr(Path, "read_text", fail_unreadable_doc) await tool.run() diff --git a/tests/unittest/test_pr_help_message_resources.py b/tests/unittest/test_pr_help_message_resources.py new file mode 100644 index 0000000000..94b38e6738 --- /dev/null +++ b/tests/unittest/test_pr_help_message_resources.py @@ -0,0 +1,383 @@ +"""Regression tests for the question-mode /help documentation corpus.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from pr_agent.tools import pr_help_message +from pr_agent.tools.pr_help_message import HELP_DOCS_UNAVAILABLE_MESSAGE, PRHelpMessage + + +def _write_document(root: Path, relative_path: str, content: str) -> Path: + document = root / relative_path + document.parent.mkdir(parents=True, exist_ok=True) + document.write_text(content, encoding="utf-8") + return document + + +@pytest.mark.parametrize( + ("relative_path", "expected"), + [ + ("tools/review.md", True), + ("tools/review.txt", False), + ("core-abilities/compression_strategy.md", False), + ("finetuning_benchmark/sample.md", False), + ("nested/finetuning_benchmark/sample.md", False), + ], +) +def test_help_doc_inclusion_predicate(relative_path, expected): + assert pr_help_message._is_help_doc_included(pr_help_message.PurePosixPath(relative_path)) is expected + + +def test_packaged_help_docs_are_filtered_and_priority_sorted(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + _write_document(docs_root, "index.md", "root") + _write_document(docs_root, "tools/review.md", "review") + _write_document(docs_root, "reference/other.md", "other") + _write_document(docs_root, "core-abilities/compression_strategy.md", "excluded") + _write_document(docs_root, "finetuning_benchmark/sample.md", "excluded") + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + + prompt, available_docs_files = pr_help_message._load_help_docs_prompt() + + assert prompt.index("/index.md") < prompt.index("/tools/review.md") < prompt.index("/reference/other.md") + assert "compression_strategy" not in prompt + assert "finetuning_benchmark" not in prompt + assert available_docs_files == {"index.md", "tools/review.md", "reference/other.md"} + + +def test_source_checkout_is_used_when_packaged_docs_are_absent(tmp_path, monkeypatch): + package_root = tmp_path / "installed" / "pr_agent" + package_root.mkdir(parents=True) + source_module = tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py" + _write_document(tmp_path / "checkout" / "docs" / "docs", "tools/review.md", "source checkout") + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(pr_help_message, "__file__", str(source_module)) + + prompt, available_docs_files = pr_help_message._load_help_docs_prompt() + + assert "/tools/review.md" in prompt + assert "source checkout" in prompt + assert available_docs_files == {"tools/review.md"} + + +def test_source_fallback_preserves_packaged_resource_traceback(tmp_path, monkeypatch): + source_module = tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py" + source_docs = tmp_path / "checkout" / "docs" / "docs" + _write_document(source_docs, "tools/review.md", "source checkout") + logger = Mock() + + def fail_package_lookup(_package): + raise OSError("broken resource provider") + + monkeypatch.setattr(pr_help_message, "package_files", fail_package_lookup) + monkeypatch.setattr(pr_help_message, "__file__", str(source_module)) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + assert pr_help_message._get_help_docs_root() == source_docs + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.debug.assert_called_once_with( + "Unable to inspect packaged /help documentation" + ) + + +def test_traversal_failures_preserve_tracebacks(tmp_path, monkeypatch): + docs_root = tmp_path / "docs" + broken_document = _write_document(docs_root, "broken.md", "unreadable metadata") + original_is_dir = Path.is_dir + logger = Mock() + + def is_dir(path): + if path == broken_document: + raise OSError("broken stat") + return original_is_dir(path) + + monkeypatch.setattr(Path, "is_dir", is_dir) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + assert pr_help_message._iter_help_docs(docs_root) == [] + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while inspecting the documentation path {broken_document}" + ) + + +def test_directory_listing_failure_preserves_traceback(tmp_path, monkeypatch): + docs_root = tmp_path / "docs" + docs_root.mkdir() + original_iterdir = Path.iterdir + logger = Mock() + + def iterdir(path): + if path == docs_root: + raise OSError("broken listing") + return original_iterdir(path) + + monkeypatch.setattr(Path, "iterdir", iterdir) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + assert pr_help_message._iter_help_docs(docs_root) == [] + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while listing the documentation directory {docs_root}" + ) + + +def test_unreadable_document_is_skipped_when_another_document_loads(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + _write_document(docs_root, "good.md", "readable") + bad_document = _write_document(docs_root, "bad.md", "unreadable") + _write_document(docs_root, "empty.md", " \n\t") + original_read_text = Path.read_text + logger = Mock() + + def read_text(document, *args, **kwargs): + if document == bad_document: + raise OSError("permission denied") + return original_read_text(document, *args, **kwargs) + + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(Path, "read_text", read_text) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + prompt, available_docs_files = pr_help_message._load_help_docs_prompt() + + assert "readable" in prompt + assert "/bad.md" not in prompt + assert "/empty.md" not in prompt + assert available_docs_files == {"good.md"} + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while reading the documentation file {bad_document}" + ) + + +@pytest.mark.asyncio +async def test_missing_corpus_fails_before_model_attempt(tmp_path, monkeypatch): + package_root = tmp_path / "installed" / "pr_agent" + package_root.mkdir(parents=True) + retry = AsyncMock() + logger = Mock() + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr( + pr_help_message, + "__file__", + str(tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py"), + ) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=True)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError): + await tool.run() + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_called_once_with(HELP_DOCS_UNAVAILABLE_MESSAGE) + logger.exception.assert_called_once() + assert "Unable to load the PR-Agent help documentation" in logger.exception.call_args.args[0] + + +@pytest.mark.parametrize("failure_method", ["resolve", "is_dir"]) +@pytest.mark.asyncio +async def test_source_fallback_failure_notifies_before_model_attempt(tmp_path, monkeypatch, failure_method): + package_root = tmp_path / "installed" / "pr_agent" + package_root.mkdir(parents=True) + source_module = tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py" + source_error = OSError("broken source checkout") + retry = AsyncMock() + logger = Mock() + source_docs = source_module.resolve().parents[2] / "docs" / "docs" + + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(pr_help_message, "__file__", str(source_module)) + if failure_method == "resolve": + original_resolve = Path.resolve + + def resolve(path, *args, **kwargs): + if path == source_module: + raise source_error + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", resolve) + else: + original_is_dir = Path.is_dir + + def is_dir(path): + if path == source_docs: + raise source_error + return original_is_dir(path) + + monkeypatch.setattr(Path, "is_dir", is_dir) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=True)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError) as raised: + await tool.run() + + assert raised.value.__cause__ is source_error + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_called_once_with(HELP_DOCS_UNAVAILABLE_MESSAGE) + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + "Unable to inspect source-tree /help documentation" + ) + logger.exception.assert_called_once_with("Unable to load the PR-Agent help documentation") + + +@pytest.mark.asyncio +async def test_unreadable_corpus_fails_before_model_attempt(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + unreadable_document = _write_document(docs_root, "tools/review.md", "unreadable") + retry = AsyncMock() + logger = Mock() + original_read_text = Path.read_text + + def read_text(document, *args, **kwargs): + if document == unreadable_document: + raise OSError("permission denied") + return original_read_text(document, *args, **kwargs) + + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(Path, "read_text", read_text) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=False)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError): + await tool.run() + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_not_called() + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while reading the documentation file {unreadable_document}" + ) + logger.exception.assert_called_once() + assert "Unable to load the PR-Agent help documentation" in logger.exception.call_args.args[0] + + +@pytest.mark.asyncio +async def test_empty_corpus_fails_before_model_attempt(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + _write_document(docs_root, "empty.md", "") + _write_document(docs_root, "whitespace.md", " \n\t") + retry = AsyncMock() + logger = Mock() + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=False)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError): + await tool.run() + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_called_once() + assert "Unable to load the PR-Agent help documentation" in logger.exception.call_args.args[0] + + +@pytest.mark.asyncio +async def test_notification_failure_preserves_original_corpus_error(monkeypatch): + original_error = FileNotFoundError("missing corpus") + retry = AsyncMock() + logger = Mock() + + def fail_load(): + raise original_error + + monkeypatch.setattr(pr_help_message, "_load_help_docs_prompt", fail_load) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=True)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(side_effect=RuntimeError("provider unavailable")), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError) as raised: + await tool.run() + + assert raised.value is original_error + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_called_once_with(HELP_DOCS_UNAVAILABLE_MESSAGE) + assert logger.exception.call_count == 2 + assert logger.exception.call_args_list[0].args == ("Unable to load the PR-Agent help documentation",) + assert logger.exception.call_args_list[1].args == ( + "Unable to publish the help documentation failure message", + ) From e0b6efbdda71f1cd5e47841d26211f85bdba25a1 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:44:17 +0800 Subject: [PATCH 116/238] chore(deps): Upgrade LiteLLM to the latest stable v1.101.0 (#3404) --- docs/docs/usage-guide/changing_a_model.md | 2 +- .../algo/ai_handlers/litellm_ai_handler.py | 65 +++++++++++++------ pyproject.toml | 4 +- tests/unittest/test_litellm_api_key_guard.py | 25 +++++++ .../unittest/test_litellm_reasoning_effort.py | 57 ++++++++-------- uv.lock | 20 +++--- 6 files changed, 109 insertions(+), 64 deletions(-) diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 20fd9b97c5..501d889028 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -712,7 +712,7 @@ model = "github_copilot/gpt-4o" fallback_models = ["github_copilot/gpt-4.1"] ``` -The GitHub identity behind the model needs an active Copilot subscription. The token budget for a Copilot model is resolved automatically from litellm's model metadata (verified against the pinned litellm 1.100.0), so `custom_model_max_tokens` is not required. However, `get_max_tokens` clamps the effective window to `config.max_model_tokens`, which defaults to 32000. To use the full context window of the model (e.g., 64000 for gpt-4o, 128000 for gpt-4.1), raise `config.max_model_tokens` accordingly. +The GitHub identity behind the model needs an active Copilot subscription. The token budget for a Copilot model is resolved automatically from litellm's model metadata (verified against the pinned litellm 1.101.0), so `custom_model_max_tokens` is not required. However, `get_max_tokens` clamps the effective window to `config.max_model_tokens`, which defaults to 32000. To use the full context window of the model (e.g., 64000 for gpt-4o, 128000 for gpt-4.1), raise `config.max_model_tokens` accordingly. Authentication uses the [GitHub Copilot provider](https://docs.litellm.ai/docs/providers/github_copilot) flow: diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 46624faf49..7ccca2f813 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -104,7 +104,7 @@ "vertex_ai_beta": "vertex_ai", } -# Keep chat-completion endpoint aliases in the same precedence order as LiteLLM 1.100.0. +# Keep chat-completion endpoint aliases in the same precedence order as LiteLLM 1.101.0. # Native completion masks BASETEN_API_BASE, MISTRAL_API_BASE and ARK_API_BASE; # VERTEX_API_BASE belongs to embedding, not chat. Do not promote them to explicit routing. PROVIDER_API_BASE_ENV_VARS = { @@ -228,7 +228,7 @@ "watsonx_text": dict(_WATSONX_ROUTING_ENV_VARS), } -# Keep aliases in the same precedence order as LiteLLM 1.100.0. +# Keep aliases in the same precedence order as LiteLLM 1.101.0. PROVIDER_API_KEY_ENV_VARS = { "ai21": ("AI21_API_KEY",), "ai21_chat": ("AI21_API_KEY",), @@ -618,9 +618,13 @@ def resolver(litellm_params): @wraps(original_resolve_env) def resolve_env(owner, litellm_params, param_key, env_var_key): - context = _azure_oidc_request.get() + context = _azure_oidc_request.get() or _azure_ad_responses_request.get() if ( - context is not None and litellm_params.get("azure_ad_token") == context["dispatch_token"] + context is not None + and ( + litellm_params.get("azure_ad_token") == context["dispatch_token"] + or (context.get("companion") and litellm_params.get("azure_ad_token") is None) + ) and env_var_key in context["auth_environment"] ): value = litellm_params.get(param_key) @@ -629,12 +633,15 @@ def resolve_env(owner, litellm_params, param_key, env_var_key): @wraps(original_initialize) def initialize(*args, **kwargs): - context = _azure_oidc_request.get() + context = _azure_oidc_request.get() or _azure_ad_responses_request.get() if context is None: return original_initialize(*args, **kwargs) bound = initialize_signature.bind(*args, **kwargs) params = bound.arguments.get("litellm_params") or {} - if params.get("azure_ad_token") != context["dispatch_token"]: + if not ( + params.get("azure_ad_token") == context["dispatch_token"] + or (context.get("companion") and params.get("azure_ad_token") is None) + ): return original_initialize(*args, **kwargs) native_globals = _azure_oidc_companion_globals(original_initialize.__globals__, context) factory = FunctionType( @@ -647,10 +654,16 @@ def initialize(*args, **kwargs): @wraps(original_cache_key) def cache_key(client_initialization_params, client_type): key = original_cache_key(client_initialization_params, client_type) - context = _azure_oidc_request.get() + context = _azure_oidc_request.get() or _azure_ad_responses_request.get() if ( context is not None and client_type == "azure" - and client_initialization_params.get("azure_ad_token") == context["selector_hash"] + and ( + client_initialization_params.get("azure_ad_token") == context.get("selector_hash") + or ( + context.get("companion") + and client_initialization_params.get("azure_ad_token") is None + ) + ) ): return f"{key}|pr_agent_oidc={context['identity_hash']}" return key @@ -658,13 +671,18 @@ def cache_key(client_initialization_params, client_type): @wraps(original_client) def client(*args, **kwargs): context = _azure_oidc_request.get() + if context is None: + context = _azure_ad_responses_request.get() if context is None or not context["generated_guard"]: return original_client(*args, **kwargs) bound = client_signature.bind(*args, **kwargs) params = bound.arguments.get("litellm_params") or {} if ( bound.arguments.get("api_key") == DUMMY_LITELLM_API_KEY - and params.get("azure_ad_token") == context["dispatch_token"] + and ( + params.get("azure_ad_token") == context["dispatch_token"] + or (context.get("companion") and params.get("azure_ad_token") is None) + ) ): # Dispatch has already resolved fallbacks. Restore native keyless # selection before the SDK initializer and its client-cache lookup. @@ -986,7 +1004,7 @@ def _get_bedrock_model_region(model: str, model_id=None) -> str | None: """Resolve only the region carried by the request, without ambient AWS discovery.""" from litellm.llms.bedrock.common_utils import BedrockModelInfo - # LiteLLM 1.100.0 consumes model_id before Invoke resolves its region. + # LiteLLM 1.101.0 consumes model_id before Invoke resolves its region. # Only Converse uses that separate ID or region/model path for routing. is_converse = BedrockModelInfo.get_bedrock_route(model) == "converse" if not is_converse: @@ -1002,7 +1020,7 @@ def _get_bedrock_model_region(model: str, model_id=None) -> str | None: arn_candidate = candidate if not model_id: arn_candidate = arn_candidate.removeprefix("invoke/") - # Match the native chat model prefixes in LiteLLM 1.100.0 without + # Match the native chat model prefixes in LiteLLM 1.101.0 without # decoding an ARN or changing Converse's region/model path grammar. for prefix in ("llama/", "deepseek_r1/", "openai/", "qwen2/", "qwen3/", "moonshot/", "nova-2/", "nova/"): if arn_candidate.startswith(prefix): @@ -1072,7 +1090,7 @@ def _guard_request_routing_globals(provider: str | None, params: dict) -> dict: and parameter in params and live_value != params[parameter] ): - # LiteLLM 1.100.0's ChatGPT chat transformation ignores the request + # LiteLLM 1.101.0's ChatGPT chat transformation ignores the request # api_base and re-reads these variables when resolving provider info. raise ValueError("Refusing changed live api_base environment for provider chatgpt") return params @@ -1584,7 +1602,7 @@ def guarded(*args, **kwargs): setattr(guarded, marker, original) return guarded - # LiteLLM 1.100.0 reads expired (sync) or token_state (async) on the + # LiteLLM 1.101.0 reads expired (sync) or token_state (async) on the # actual cached credential before returning its token. Guarding the # descriptors also covers cache replacement during an async lock wait. for name in ("from_info", "refresh", "expired", "token_state"): @@ -1611,7 +1629,7 @@ def guarded(*args, **kwargs): def _install_vertex_wif_project_bridge(): - """Restore ADC project discovery for request-owned WIF JSON in LiteLLM 1.100.0.""" + """Restore ADC project discovery for request-owned WIF JSON in LiteLLM 1.101.0.""" from litellm.llms.vertex_ai.vertex_llm_base import VertexBase original = VertexBase.load_auth @@ -1630,7 +1648,7 @@ def load_auth(self, credentials, project_id): if json_obj != snapshot: return original(self, credentials, project_id) - # Preserve LiteLLM 1.100.0's WIF factories, including its explicit AWS + # Preserve LiteLLM 1.101.0's WIF factories, including its explicit AWS # supplier. Only project discovery is missing from its JSON load path. scopes = ["https://www.googleapis.com/auth/cloud-platform"] source = json_obj.get("credential_source", {}) @@ -1672,7 +1690,7 @@ def load_auth(self, credentials, project_id): def _install_vertex_impersonated_credentials_bridge(): - """Add LiteLLM 1.100.0's missing ADC type without replacing its refresh/cache path.""" + """Add LiteLLM 1.101.0's missing ADC type without replacing its refresh/cache path.""" from litellm.llms.vertex_ai.vertex_llm_base import VertexBase original = VertexBase._credentials_from_service_account @@ -3094,7 +3112,7 @@ def _get_provider_request_params( if provider == "bedrock" and "api_key" not in params and _has_live_provider_api_key_environment(provider): raise ValueError("Refusing process-wide Bedrock bearer token fallback") if provider in ("sagemaker_chat", "sagemaker_nova") and os.environ.get("AWS_BEARER_TOKEN_BEDROCK"): - # LiteLLM 1.100.0's SageMaker signer ignores its api_key argument and + # LiteLLM 1.101.0's SageMaker signer ignores its api_key argument and # otherwise reads this Bedrock-only token directly from the environment. raise ValueError("Refusing Bedrock bearer token fallback for SageMaker") if provider == "azure" and getattr(self, "_azure_ad", False): @@ -3173,7 +3191,7 @@ def _get_provider_request_params( ) if not uses_bedrock_bearer: if any(os.environ.get(variable) for variable in LITELLM_AWS_CREDENTIAL_SELECTOR_ENV_VARS): - # LiteLLM 1.100.0 resolves these selectors ahead of explicit + # LiteLLM 1.101.0 resolves these selectors ahead of explicit # request credentials, which would replace the isolated keys. raise ValueError(f"Refusing ambient LiteLLM AWS credential selector for provider {provider}") aws_request_credentials = dict(aws_request_credentials or {}) @@ -4035,8 +4053,8 @@ async def _chat_completion_with_retry( ) or [] except Exception: supported_params = [] - # LiteLLM may omit reasoning_effort for grok-build-latest - # and OpenAI-compatible gateway-prefixed Grok IDs. + # LiteLLM may omit reasoning_effort for unknown or + # OpenAI-compatible gateway-prefixed Grok IDs. if "reasoning_effort" not in supported_params: kwargs["allowed_openai_params"] = ["reasoning_effort"] @@ -4487,6 +4505,13 @@ async def _acompletion( "companion": companion_auth, "environment": environment, "auth_environment": {**environment, **self._azure_oidc_auth_environment}, + "selector_hash": hashlib.sha256(azure_ad_token.encode()).hexdigest() + if isinstance(azure_ad_token, str) else None, + "identity_hash": hashlib.sha256( + json.dumps( + {**environment, **self._azure_oidc_auth_environment}, sort_keys=True + ).encode() + ).hexdigest(), }) if provider == "azure" and (oidc_selector or ordinary_sdk_auth): _install_azure_oidc_bridge() diff --git a/pyproject.toml b/pyproject.toml index 964de7837d..f95602ff91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ # Revalidate compatibility when updating grpcio-status or related Google/gRPC dependencies. "protobuf==6.33.6", "Jinja2==3.1.6", - "litellm==1.100.0", + "litellm==1.101.0", "loguru==0.7.2", "msrest==0.7.1", "openai>=1.55.3", @@ -69,7 +69,7 @@ dependencies = [ "langfuse==3.14.5", "gunicorn==23.0.0", "pydantic==2.13.3", - # litellm 1.100.0 declares pydantic-settings as a base dependency and imports it unconditionally from + # litellm 1.101.0 declares pydantic-settings as a base dependency and imports it unconditionally from # litellm/integrations/otel/model/config.py. Keep the exact pin to lock the resolved transitive version. # Version matches litellm's own constraint (>=2.14.1,<3.0); the existing observability guard covers the callback import. "pydantic-settings==2.14.2", diff --git a/tests/unittest/test_litellm_api_key_guard.py b/tests/unittest/test_litellm_api_key_guard.py index f05ec35703..168e0696b7 100644 --- a/tests/unittest/test_litellm_api_key_guard.py +++ b/tests/unittest/test_litellm_api_key_guard.py @@ -902,6 +902,31 @@ async def test_native_azure_ad_responses_companion(monkeypatch, native_azure_oid provider.assert_not_called() +@pytest.mark.asyncio +async def test_native_azure_ad_responses_scopes_companion_client_cache(monkeypatch, native_azure_oidc): + import azure.identity + + state = native_azure_oidc + state.response_kind = "responses" + state.expected_url = "https://owned.openai.azure.com/openai/responses?api-version=2024-06-01" + monkeypatch.setenv("AZURE_AD_TOKEN", "shared-ad-token") + monkeypatch.setenv("AZURE_CLIENT_SECRET", "first-secret") + monkeypatch.setattr(litellm, "enable_azure_ad_token_refresh", True) + first = LiteLLMAIHandler() + monkeypatch.setenv("AZURE_CLIENT_SECRET", "second-secret") + second = LiteLLMAIHandler() + constructor = MagicMock(wraps=azure.identity.ClientSecretCredential) + monkeypatch.setattr(azure.identity, "ClientSecretCredential", constructor) + tokens = iter(("first-provider-token", "second-provider-token")) + provider = MagicMock(side_effect=lambda *args, **kwargs: lambda: next(tokens)) + monkeypatch.setattr(azure.identity, "get_bearer_token_provider", provider) + + assert await state.invoke(first, model="azure/responses/gpt-4o") == "Bearer first-provider-token" + assert await state.invoke(second, model="azure/responses/gpt-4o") == "Bearer second-provider-token" + assert constructor.call_count == 2 + assert provider.call_count == 2 + + @pytest.mark.parametrize("transport", ("classic", "v1")) @pytest.mark.parametrize("entrypoint", ("chat", "probe")) @pytest.mark.parametrize("source", ("client_secret", "password", "both", "incomplete", "password_without_tenant")) diff --git a/tests/unittest/test_litellm_reasoning_effort.py b/tests/unittest/test_litellm_reasoning_effort.py index 5ced98a17e..c9a3a14e42 100644 --- a/tests/unittest/test_litellm_reasoning_effort.py +++ b/tests/unittest/test_litellm_reasoning_effort.py @@ -1132,16 +1132,16 @@ def test_clamp_grok_reasoning_effort(self, model, configured, expected): assert LiteLLMAIHandler._clamp_grok_reasoning_effort(model, configured) == expected @pytest.mark.parametrize( - ("model", "effort", "requires_allowlist"), + ("model", "effort"), [ - ("grok-4.5", "low", False), - ("grok-4.5-latest", "low", False), - ("grok-build-latest", "low", True), - ("grok-4.6", "xhigh", False), + ("grok-4.5", "low"), + ("grok-4.5-latest", "low"), + ("grok-build-latest", "low"), + ("grok-4.6", "xhigh"), ], ) - def test_xai_grok_litellm_reasoning_param_support(self, monkeypatch, model, effort, requires_allowlist): - """Pin LiteLLM capability gaps so upgrades expose removable workarounds.""" + def test_xai_grok_litellm_reasoning_param_support(self, monkeypatch, model, effort): + """Verify LiteLLM forwards reasoning_effort for every supported Grok alias.""" monkeypatch.setattr(litellm, "drop_params", False) bundled_model_cost = GetModelCostMap.load_local_model_cost_map() pinned_model_cost = dict(litellm.model_cost) @@ -1154,20 +1154,10 @@ def test_xai_grok_litellm_reasoning_param_support(self, monkeypatch, model, effo with monkeypatch.context() as registry_patch: registry_patch.setattr(litellm, "model_cost", pinned_model_cost) litellm_utils._invalidate_model_cost_lowercase_map() - if requires_allowlist: - with pytest.raises(litellm.UnsupportedParamsError): - get_optional_params( - model=model, - custom_llm_provider="xai", - reasoning_effort=effort, - ) - - allowed = ["reasoning_effort"] if requires_allowlist else None params = get_optional_params( model=model, custom_llm_provider="xai", reasoning_effort=effort, - allowed_openai_params=allowed, ) assert params["reasoning_effort"] == effort finally: @@ -1175,20 +1165,25 @@ def test_xai_grok_litellm_reasoning_param_support(self, monkeypatch, model, effo def test_openai_gateway_grok_litellm_reasoning_param_support(self, monkeypatch): monkeypatch.setattr(litellm, "drop_params", False) - with pytest.raises(litellm.UnsupportedParamsError): - get_optional_params( - model="x-ai/grok-4.6", - custom_llm_provider="openai", - reasoning_effort="xhigh", - ) - - params = get_optional_params( - model="x-ai/grok-4.6", - custom_llm_provider="openai", - reasoning_effort="xhigh", - allowed_openai_params=["reasoning_effort"], - ) - assert params["reasoning_effort"] == "xhigh" + bundled_model_cost = GetModelCostMap.load_local_model_cost_map() + pinned_model_cost = dict(litellm.model_cost) + for model_key in ("x-ai/grok-4.6", "openai/x-ai/grok-4.6"): + if model_key in bundled_model_cost: + pinned_model_cost[model_key] = bundled_model_cost[model_key] + else: + pinned_model_cost.pop(model_key, None) + try: + with monkeypatch.context() as registry_patch: + registry_patch.setattr(litellm, "model_cost", pinned_model_cost) + litellm_utils._invalidate_model_cost_lowercase_map() + params = get_optional_params( + model="x-ai/grok-4.6", + custom_llm_provider="openai", + reasoning_effort="xhigh", + ) + assert params["reasoning_effort"] == "xhigh" + finally: + litellm_utils._invalidate_model_cost_lowercase_map() @pytest.mark.asyncio @pytest.mark.parametrize( diff --git a/uv.lock b/uv.lock index dd986690ab..0cb99e5ab3 100644 --- a/uv.lock +++ b/uv.lock @@ -1772,7 +1772,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.100.0" +version = "1.101.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1790,15 +1790,15 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/ce/1e1ce2558f65244057c0c40c60ade4dfa3767da3522bf1dd8679507ad7ba/litellm-1.100.0.tar.gz", hash = "sha256:ece94e817a453a5b3a9517c03547c428d501cea719edb728c1b260e53f78ea35", size = 17287857, upload-time = "2026-09-06T00:22:46.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c0/08a31c8c5f7fce96e98e2a7f818f446d0468da484228a7450cd2b6f71dcf/litellm-1.101.0.tar.gz", hash = "sha256:734ab2b8cad6a3b582d52d9c9c5fcab759eb382b93935ef808fda0e16d822ac3", size = 17447699, upload-time = "2026-09-14T23:15:36.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/92/983d15efffd9bab37ebbff255fb09417814ecd959bd2a2459b8afb6c205d/litellm-1.100.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:098a413e398e2220734cf9c8dd75fb34a38b65b64090619c2869af1fdeaf4ae5", size = 23899430, upload-time = "2026-09-06T00:22:21.941Z" }, - { url = "https://files.pythonhosted.org/packages/59/e1/ea3f868ad6c84b2232f41d5d56adb5f0794ebb1cf32fc0e78effa5842170/litellm-1.100.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0f87fae695edbca27e5cf970bea52fa405fa9910fdf7c29c963d6413db767299", size = 23552365, upload-time = "2026-09-06T00:22:25.456Z" }, - { url = "https://files.pythonhosted.org/packages/61/50/9439bd3238c9d8c7efcd5165555f175cc8e99e748ba99e8cc7e502787265/litellm-1.100.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a07370d116905485e9ac99679bac991a0a6c81e13c99ca3f007128fbdf2b0082", size = 23693340, upload-time = "2026-09-06T00:22:28.549Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/34a1df3a5fe1db92bf43d3b25af2e5a9c36c54ab6813acef0c31cce2b4e0/litellm-1.100.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8224c8eed9cab3319a88e6665d1275ad8faf21d353b1b22223a6d6115a302ea2", size = 24063361, upload-time = "2026-09-06T00:22:31.913Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d0/4c7e8c8402e5af8c9f55602484c2bc888f0a8fe5c2a01df2a3705ba2139b/litellm-1.100.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e3787fb7ad1f20aebdde7686a85061880bba6550c9d62bfa1cf8df089fe7899b", size = 23766707, upload-time = "2026-09-06T00:22:35.06Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a2/81595fcda1457b777739d265b9383c1061c884af37111223b5fecbf72588/litellm-1.100.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0b7ec93013e18535481cd811b776ee95c6b957b3a9fb44dc53f7c459e7e60e38", size = 24163673, upload-time = "2026-09-06T00:22:38.418Z" }, - { url = "https://files.pythonhosted.org/packages/ce/96/1ee7a3c86c4e9afec528d094b30158cd55b8bbc619e1632ad897bbd1291a/litellm-1.100.0-cp310-abi3-win_amd64.whl", hash = "sha256:c6f2f56808d05d8d2a7766129d958101eafb1a47e30ba5ddcfa900cdbc50af67", size = 23974360, upload-time = "2026-09-06T00:22:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/ae/cf/6b3a687ab0ab4caacc35faaf695f6862654e21e2a5dc703fa9e1df6d7069/litellm-1.101.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7cc623a224c6f11a04367a682b095a1e08e5f6da75c990a650910db5f9777819", size = 23777389, upload-time = "2026-09-14T23:15:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/42/f6/d5bc3aa1944244fd186e65ca16e62158f0f474b40f99a89e568a5d441739/litellm-1.101.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d4064024151ff2877e542b56c6bb6a39e0c3e6651abf4639af9346a678586e52", size = 23434831, upload-time = "2026-09-14T23:15:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/ba/12/755e4d497b975911449102b8b74d3d4f770137c4467e3e8fe304adf22d66/litellm-1.101.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdf3351e394206e785bf339f1a39d9358ddf4e4f38129750bfdb5e4f5ceab8ef", size = 23568059, upload-time = "2026-09-14T23:15:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0e/561d7a314a08940688814be590544907fdfde9225dce35badbd79fc9ad4c/litellm-1.101.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:140ee0997324b8fd2405f7f8c26f42c0f364996dc4030187344a90cf27916667", size = 23944856, upload-time = "2026-09-14T23:15:26.417Z" }, + { url = "https://files.pythonhosted.org/packages/f1/df/d640eb6cf4a304be4ec289b72c1b1d9bd66c73e90167211d3a09ac37b2d8/litellm-1.101.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ad013161074676fc91b91d828f696300132cf3936d3f386c4701a41710d70aa3", size = 23643397, upload-time = "2026-09-14T23:15:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b6/9d69e95de9465cd23651c6623518d5e711d8776a0e58e6ea62066c9d9470/litellm-1.101.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d88053148c5c6e016e495273eeaf8f3a29779b60d18977ec42e2a6bbd2d8eb", size = 24042718, upload-time = "2026-09-14T23:15:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b5/0a54001992c9a8d991f6ddd7532e3c4cb7dbdcc94220a37586513aab6f89/litellm-1.101.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5601ae404b0f1e38ce1e65c75f880254485942d9c824453e3926f2323457fd5", size = 23844988, upload-time = "2026-09-14T23:15:33.632Z" }, ] [[package]] @@ -2563,7 +2563,7 @@ requires-dist = [ { name = "langchain-core", marker = "extra == 'langchain'" }, { name = "langchain-openai", marker = "extra == 'langchain'" }, { name = "langfuse", specifier = "==3.14.5" }, - { name = "litellm", specifier = "==1.100.0" }, + { name = "litellm", specifier = "==1.101.0" }, { name = "loguru", specifier = "==0.7.2" }, { name = "mangum", marker = "extra == 'lambda'", specifier = "==0.17.0" }, { name = "msrest", specifier = "==0.7.1" }, From 5e448cacc01400f67b206c3a17591f8b4eeb01a2 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:36:56 +0545 Subject: [PATCH 117/238] fix(bitbucket): hide review and suggestion identity markers (#3386) --- docs/docs/installation/bitbucket.md | 6 + pr_agent/algo/utils.py | 30 ++++- pr_agent/git_providers/bitbucket_provider.py | 3 + pr_agent/git_providers/git_provider.py | 9 +- pr_agent/tools/pr_code_suggestions.py | 10 +- pr_agent/tools/pr_reviewer.py | 10 +- tests/unittest/test_bitbucket_provider.py | 6 +- tests/unittest/test_hidden_comment_markers.py | 107 ++++++++++++++++++ .../unittest/test_review_comment_identity.py | 2 +- 9 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 tests/unittest/test_hidden_comment_markers.py diff --git a/docs/docs/installation/bitbucket.md b/docs/docs/installation/bitbucket.md index 56ba0b996a..ff82614ce2 100644 --- a/docs/docs/installation/bitbucket.md +++ b/docs/docs/installation/bitbucket.md @@ -28,6 +28,12 @@ For basic auth, you can generate a base64 encoded token from your username:passw Note that comments on a PR are not supported in Bitbucket Pipeline. +### Persistent comments on Bitbucket Cloud + +Review and code-suggestion identity markers use invisible Markdown link references on Bitbucket Cloud. +Existing comments with older HTML identity markers are still recognized and updated in place. +No configuration change is required. + ## Bitbucket Server and Data Center Login into your on-prem instance of Bitbucket with your service account username and password. diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 3227a756d2..3ba0fa0413 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -156,13 +156,30 @@ def format_pr_questions_header(*, escape_markdown: bool = True) -> str: return f"### **{heading}** ❓" +def hidden_marker_forms(identity: str) -> tuple[str, ...]: + """Return both stored forms of a known comment identity.""" + for marker in _ALL_COMMENT_IDENTITIES: + reference = f"[{marker[5:-4]}]: https://github.com/The-PR-Agent/pr-agent" + if identity in (marker, reference): + return marker, reference + return (identity,) + + +def render_hidden_marker(identity: str, git_provider=None) -> str: + """Use a link reference on providers that escape HTML comments.""" + forms = hidden_marker_forms(identity) + supports_html = getattr(git_provider, "supports_html_comment_markers", lambda: True) + return forms[-1] if supports_html() is False else forms[0] + + def comment_matches_identity(body: str, identity: str) -> bool: """Match hidden markers only as exact lines near the top; legacy headers as prefixes.""" if not isinstance(body, str) or not isinstance(identity, str) or not identity: return False - if identity.startswith("\n\nbody" + "## Team Review 🔍\n\n[pr-agent:review:full]: https://github.com/The-PR-Agent/pr-agent\n\nbody" ) From 55882afbc8eee4e239429a144ff133238a2b0ca4 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Tue, 15 Sep 2026 17:52:10 -0500 Subject: [PATCH 118/238] fix(labels): validate output inside model attempts (#3393) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/algo/output_models.py | 33 +++ pr_agent/tools/pr_generate_labels.py | 64 +++-- .../test_generate_labels_entry_shape.py | 242 +++++++++++++++++- tests/unittest/test_load_yaml_unparseable.py | 9 +- tests/unittest/test_output_models.py | 35 +++ 5 files changed, 339 insertions(+), 44 deletions(-) diff --git a/pr_agent/algo/output_models.py b/pr_agent/algo/output_models.py index 9f7c47e79a..4e28b50c63 100644 --- a/pr_agent/algo/output_models.py +++ b/pr_agent/algo/output_models.py @@ -152,6 +152,39 @@ class Label(str, Enum): class Labels(BaseModel): labels: List[str] + @field_validator("labels", mode="before") + @classmethod + def _normalize_labels(cls, value): + if isinstance(value, str): + entries = value.split(",") + elif isinstance(value, list): + if not value: + return [] + entries = value + else: + raise ValueError("labels must be a list or comma-separated string") + + labels = [] + for entry in entries: + if isinstance(entry, dict): + entry = next( + ( + entry[key] + for key in ("name", "label", "title", "value") + if isinstance(entry.get(key), str) and entry[key].strip() + ), + None, + ) + if isinstance(entry, bool) or not isinstance(entry, (str, int, float)): + continue + label = str(entry).strip() + if label: + labels.append(label) + + if not labels: + raise ValueError("labels must contain at least one usable value") + return labels + class CodeDocumentationItem(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="forbid") diff --git a/pr_agent/tools/pr_generate_labels.py b/pr_agent/tools/pr_generate_labels.py index 68781d0b17..c6bdea2af4 100644 --- a/pr_agent/tools/pr_generate_labels.py +++ b/pr_agent/tools/pr_generate_labels.py @@ -6,6 +6,7 @@ from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler +from pr_agent.algo.output_models import Labels from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import get_user_labels, load_yaml, set_custom_labels @@ -15,22 +16,6 @@ from pr_agent.log import get_logger -def _label_name(label) -> str: - """Read one label entry, tolerating the mapping form a model may return. - - The prompt asks for a list of strings, but a schema presented as a class invites entries - such as ``- name: bug fix``. An entry with no readable name is dropped. - """ - if isinstance(label, dict): - for key in ("name", "label", "title", "value"): - if isinstance(label.get(key), str) and label[key].strip(): - return label[key].strip() - return "" - if isinstance(label, bool) or not isinstance(label, (str, int, float)): - return "" - return str(label).strip() - - class PRGenerateLabels: def __init__(self, pr_url: str, args: list = None, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler): @@ -73,26 +58,28 @@ def __init__(self, pr_url: str, args: list = None, get_settings().pr_custom_labels_prompt.user, ) - # Initialize patches_diff and prediction attributes + # Initialize prediction state self.patches_diff = None self.prediction = None + self.data = None async def run(self): """ Generates a PR labels using an AI model and publishes it to the PR. """ + progress_comment = None try: get_logger().info(f"Generating a PR labels {self.pr_id}") if get_settings().config.publish_output: - self.git_provider.publish_comment("Preparing PR labels...", is_temporary=True) + progress_comment = self.git_provider.publish_comment( + "Preparing PR labels...", is_temporary=True + ) await retry_with_fallback_models(self._prepare_prediction, git_provider=self.git_provider) get_logger().info(f"Preparing answer {self.pr_id}") - if self.prediction: - self._prepare_data() - else: + if self.prediction is None: return None pr_labels = self._prepare_labels() @@ -110,11 +97,16 @@ async def run(self): value = ', '.join(v for v in pr_labels) pr_labels_text = f"## PR Labels:\n{value}\n" self.git_provider.publish_comment(pr_labels_text, is_temporary=False) - self.git_provider.remove_initial_comment() except Exception as e: get_logger().error(f"Error generating PR labels {self.pr_id}: {e}") if get_settings().config.get("propagate_tool_errors", False): raise + finally: + if progress_comment is not None: + try: + self.git_provider.remove_initial_comment() + except Exception as e: + get_logger().error(f"Failed to remove temporary PR labels comment {self.pr_id}: {e}") return "" @@ -133,10 +125,17 @@ async def _prepare_prediction(self, model: str) -> None: """ + self.prediction = None + self.data = None + get_logger().info(f"Getting PR diff {self.pr_id}") self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) get_logger().info(f"Getting AI prediction {self.pr_id}") - self.prediction = await self._get_prediction(model) + prediction = await self._get_prediction(model) + data = self._load_valid_labels_yaml(prediction) + + self.prediction = prediction + self.data = data async def _get_prediction(self, model: str) -> str: """ @@ -168,21 +167,16 @@ async def _get_prediction(self, model: str) -> str: return response def _prepare_data(self): - # Load the AI prediction data into a dictionary - self.data = load_yaml(self.prediction.strip()) - + self.data = self._load_valid_labels_yaml(self.prediction) + @staticmethod + def _load_valid_labels_yaml(prediction: str) -> dict: + """Load a usable labels response or fail the current model attempt.""" + data = load_yaml(prediction.strip()) + return Labels.model_validate(data).model_dump() def _prepare_labels(self) -> List[str]: - pr_types = [] - - # If the 'labels' key is present in the dictionary, split its value by comma and assign it to 'pr_types' - if "labels" in self.data: - if isinstance(self.data["labels"], list): - pr_types = self.data["labels"] - elif isinstance(self.data["labels"], str): - pr_types = self.data["labels"].split(",") - pr_types = [name for name in (_label_name(label) for label in pr_types) if name] + pr_types = self.data["labels"].copy() # convert lowercase labels to original case try: diff --git a/tests/unittest/test_generate_labels_entry_shape.py b/tests/unittest/test_generate_labels_entry_shape.py index dda9d10f86..e3e20265ea 100644 --- a/tests/unittest/test_generate_labels_entry_shape.py +++ b/tests/unittest/test_generate_labels_entry_shape.py @@ -1,7 +1,44 @@ -"""Read the labels list whatever entry shape the model returns.""" +"""Validate and read the labels list inside each model attempt.""" +import asyncio +from unittest.mock import AsyncMock, MagicMock, call, patch + import pytest +from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.config_loader import get_settings from pr_agent.tools.pr_generate_labels import PRGenerateLabels +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +_TRACKED_KEYS = ( + "config.model", + "config.fallback_models", + "config.publish_output", + "config.propagate_tool_errors", + "config.enable_custom_labels", + "custom_labels", + "model_routing.enable", + "openai.deployment_id", + "openai.fallback_deployments", +) + +_VALID_FALLBACK = "labels:\n- bug fix\n" + + +@pytest.fixture +def fallback_models(): + snapshot = snapshot_settings(_TRACKED_KEYS) + settings = get_settings() + settings.set("config.model", "primary-model") + settings.set("config.fallback_models", ["fallback-model"]) + settings.set("config.publish_output", True) + settings.set("config.propagate_tool_errors", False) + settings.set("config.enable_custom_labels", False) + settings.set("custom_labels", []) + settings.set("model_routing.enable", False) + settings.set("openai.deployment_id", None) + settings.set("openai.fallback_deployments", []) + yield + restore_settings(snapshot) def labels(prediction, variables=None): @@ -13,6 +50,18 @@ def labels(prediction, variables=None): return tool._prepare_labels() +def label_tool(git_provider=None): + tool = PRGenerateLabels.__new__(PRGenerateLabels) + tool.git_provider = git_provider or MagicMock() + tool.token_handler = MagicMock() + tool.pr_id = "repo#1" + tool.patches_diff = None + tool.prediction = None + tool.data = None + tool.variables = {} + return tool + + def test_read_the_documented_list_of_strings(): """Keep the documented shape working exactly as before.""" assert labels("labels:\n- bug fix\n- tests\n") == ["bug fix", "tests"] @@ -38,6 +87,191 @@ def test_drop_an_entry_that_carries_no_name(): assert labels("labels:\n- {}\n- bug fix\n") == ["bug fix"] -def test_no_labels_key_returns_nothing(): - """Keep returning an empty list when the model produced no labels.""" - assert labels("types:\n- bug fix\n") == [] +def test_keep_custom_label_case_mapping(): + assert labels( + "labels:\n- bug_fix\n", + {"labels_minimal_to_labels_dict": {"bug_fix": "Bug Fix"}}, + ) == ["Bug Fix"] + + +def test_no_labels_key_is_not_a_successful_attempt(): + with pytest.raises(ValueError, match="labels"): + labels("types:\n- bug fix\n") + + +@pytest.mark.asyncio +async def test_malformed_primary_uses_valid_fallback_and_preserves_user_labels(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = ["keep-me"] + tool = label_tool(provider) + tool._get_prediction = AsyncMock(side_effect=["not valid labels yaml", _VALID_FALLBACK]) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + provider.publish_labels.assert_called_once_with(["bug fix", "keep-me"]) + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_explicit_empty_labels_is_valid_and_keeps_publication_path(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = ["keep-me"] + tool = label_tool(provider) + tool._get_prediction = AsyncMock(return_value="labels: []\n") + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + tool._get_prediction.assert_awaited_once_with("primary-model") + provider.publish_labels.assert_called_once_with(["keep-me"]) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.parametrize( + "invalid_prediction", + [ + "", + "not valid labels yaml", + "- bug fix\n", + "types:\n- bug fix\n", + "labels:\n name: bug fix\n", + "labels:\n", + "labels: 7\n", + "labels: false\n", + "labels: ' , '\n", + "labels:\n- {}\n- false\n", + ], +) +@pytest.mark.asyncio +async def test_invalid_attempt_shapes_reach_fallback(invalid_prediction, fallback_models): + tool = label_tool() + tool._get_prediction = AsyncMock(side_effect=[invalid_prediction, _VALID_FALLBACK]) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await retry_with_fallback_models(tool._prepare_prediction, git_provider=tool.git_provider) + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert tool.prediction == _VALID_FALLBACK + assert tool._prepare_labels() == ["bug fix"] + + +@pytest.mark.asyncio +async def test_usable_entries_survive_malformed_siblings_without_fallback(fallback_models): + tool = label_tool() + tool._get_prediction = AsyncMock(return_value="labels:\n- {}\n- bug fix\n- false\n") + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await retry_with_fallback_models(tool._prepare_prediction, git_provider=tool.git_provider) + + tool._get_prediction.assert_awaited_once_with("primary-model") + assert tool._prepare_labels() == ["bug fix"] + + +@pytest.mark.asyncio +async def test_all_invalid_attempts_clear_state_and_cleanup_progress_once(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + tool = label_tool(provider) + tool._get_prediction = AsyncMock( + side_effect=["types:\n- bug fix\n", "labels:\n- {}\n- false\n"] + ) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert tool.prediction is None + assert tool.data is None + provider.publish_labels.assert_not_called() + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_all_invalid_attempts_do_not_cleanup_without_progress_handle(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = None + provider.is_supported.return_value = True + tool = label_tool(provider) + tool._get_prediction = AsyncMock( + side_effect=["types:\n- bug fix\n", "labels:\n- {}\n- false\n"] + ) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert tool.prediction is None + assert tool.data is None + provider.publish_labels.assert_not_called() + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_not_called() + provider.remove_comment.assert_not_called() + + +@pytest.mark.asyncio +async def test_valid_attempt_does_not_cleanup_without_progress_handle(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = None + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = [] + tool = label_tool(provider) + tool._get_prediction = AsyncMock(return_value=_VALID_FALLBACK) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + tool._get_prediction.assert_awaited_once_with("primary-model") + provider.publish_labels.assert_called_once_with(["bug fix"]) + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_not_called() + provider.remove_comment.assert_not_called() + + +@pytest.mark.parametrize("propagate_tool_errors", [False, True]) +@pytest.mark.asyncio +async def test_cleanup_failure_is_best_effort_after_success( + fallback_models, propagate_tool_errors +): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = [] + provider.remove_initial_comment.side_effect = RuntimeError("cleanup failed") + tool = label_tool(provider) + tool._get_prediction = AsyncMock(return_value=_VALID_FALLBACK) + get_settings().set("config.propagate_tool_errors", propagate_tool_errors) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + assert await tool.run() == "" + + provider.publish_labels.assert_called_once_with(["bug fix"]) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_cancellation_after_progress_handle_cleans_up_before_propagating(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + tool = label_tool(provider) + + with ( + patch( + "pr_agent.tools.pr_generate_labels.retry_with_fallback_models", + new=AsyncMock(side_effect=asyncio.CancelledError()), + ), + pytest.raises(asyncio.CancelledError), + ): + await tool.run() + + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_called_once_with() + provider.publish_labels.assert_not_called() diff --git a/tests/unittest/test_load_yaml_unparseable.py b/tests/unittest/test_load_yaml_unparseable.py index 6e506a7298..2b69257ed2 100644 --- a/tests/unittest/test_load_yaml_unparseable.py +++ b/tests/unittest/test_load_yaml_unparseable.py @@ -46,16 +46,15 @@ def test_code_suggestions_returns_an_empty_list_instead_of_crashing(): assert tool._prepare_pr_code_suggestions(UNPARSEABLE) == {"code_suggestions": []} -def test_generate_labels_membership_check_does_not_raise(): - """Support `'labels' in self.data`, which pr_generate_labels does before anything else.""" +def test_generate_labels_rejects_an_unparseable_prediction(): + """Let /generate_labels retry another model when its output cannot be parsed.""" from pr_agent.tools.pr_generate_labels import PRGenerateLabels tool = PRGenerateLabels.__new__(PRGenerateLabels) tool.prediction = UNPARSEABLE - tool._prepare_data() - assert tool.data == {} - assert "labels" not in tool.data + with pytest.raises(ValueError): + tool._prepare_data() @pytest.mark.parametrize("payload", ["code_suggestions:\n", "code_suggestions: 5\n", diff --git a/tests/unittest/test_output_models.py b/tests/unittest/test_output_models.py index 3381698e2b..cbed2e496a 100644 --- a/tests/unittest/test_output_models.py +++ b/tests/unittest/test_output_models.py @@ -122,6 +122,41 @@ def test_output_models_validate_complete_fixtures(model, payload): model.model_validate(payload) +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"labels": [" Bug fix ", "Tests"]}, ["Bug fix", "Tests"]), + ({"labels": "Bug fix, Tests"}, ["Bug fix", "Tests"]), + ( + {"labels": [{"name": "Bug fix"}, {"label": "Tests"}, {"title": "Docs"}, + {"value": "Perf"}, 1, 2.5, {}, False]}, + ["Bug fix", "Tests", "Docs", "Perf", "1", "2.5"], + ), + ({"labels": [{"name": " ", "label": "Tests"}, {}, "Bug fix"]}, ["Tests", "Bug fix"]), + ({"labels": []}, []), + ], +) +def test_labels_normalize_tolerated_model_shapes(payload, expected): + assert Labels.model_validate(payload).labels == expected + + +@pytest.mark.parametrize( + "payload", + [ + {"labels": ""}, + {"labels": " "}, + {"labels": " , "}, + {"labels": [{}, False]}, + {"labels": 7}, + {"types": ["Bug fix"]}, + [], + ], +) +def test_labels_reject_unusable_model_shapes(payload): + with pytest.raises(ValueError): + Labels.model_validate(payload) + + def test_review_alias_accepts_prompt_field_name(): assert Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review_[1-5]": 3}).estimated_effort_to_review == 3 with pytest.raises(ValueError): From 862efa80a10627e7c6fb5ddf1b09c5b265afc0a2 Mon Sep 17 00:00:00 2001 From: Mostafa Hassanzadeh Date: Wed, 16 Sep 2026 02:22:35 +0330 Subject: [PATCH 119/238] fix: correct MAX_TOKENS entries understating LiteLLM's context window (#3196) (#3329) Co-authored-by: IsmaelMartinez --- pr_agent/algo/__init__.py | 24 +-------- tests/unittest/test_get_max_tokens.py | 73 +++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/pr_agent/algo/__init__.py b/pr_agent/algo/__init__.py index b326d217c3..3090ca8552 100644 --- a/pr_agent/algo/__init__.py +++ b/pr_agent/algo/__init__.py @@ -288,15 +288,7 @@ def _generate_claude_registries(families=None): 'gpt-4-32k': 32000, 'gpt-4.5-preview': 128000, # 128K, but may be limited by config.max_model_tokens 'gpt-4.5-preview-2025-02-27': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-5-nano': 200000, # 200K, but may be limited by config.max_model_tokens - 'gpt-5-mini': 200000, # 200K, but may be limited by config.max_model_tokens - 'gpt-5': 200000, - 'gpt-5-2025-08-07': 200000, - 'gpt-5.1': 200000, - 'gpt-5.1-2025-11-13': 200000, 'gpt-5.1-chat-latest': 200000, - 'gpt-5.1-codex': 200000, - 'gpt-5.1-codex-mini': 200000, 'gpt-5.2': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.2-2025-12-11': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.2-codex': 400000, # 400K, but may be limited by config.max_model_tokens @@ -324,9 +316,7 @@ def _generate_claude_registries(families=None): 'claude-instant-1': 100000, 'claude-2': 100000, 'deepseek/deepseek-chat': 128000, # 128K, but may be limited by config.max_model_tokens - 'deepseek/deepseek-reasoner': 64000, # 64K, but may be limited by config.max_model_tokens - 'zai/glm-5.2': 200000, # 200K, matching the Z.AI GLM-5/5.1 lineage, but may be limited by config.max_model_tokens - 'moonshot/kimi-k3': 262144, # 256K, matching the Moonshot Kimi-k2.5/k2.6 lineage, but may be limited by config.max_model_tokens + 'zai/glm-5.2': 1000000, # 1M per LiteLLM (issue #3196); kept pinned: absent from LiteLLM's bundled cost map 'openai/qwq-plus': 131072, # 131K context length, but may be limited by config.max_model_tokens "openrouter/auto": 2000000, # 2M context length, but may be limited by config.max_model_tokens "openrouter/free": 200000, # 200K context length, but may be limited by config.max_model_tokens @@ -414,25 +404,13 @@ def _generate_claude_registries(families=None): "xai/grok-build-latest": 500000, # kept pinned: absent from LiteLLM's bundled cost map "openrouter/x-ai/grok-4.5": 500000, # kept pinned: absent from LiteLLM's bundled cost map "openrouter/x-ai/grok-4.6": 500000, # kept pinned: absent from LiteLLM's bundled cost map - 'ollama/llama3': 4096, 'watsonx/meta-llama/llama-3-8b-instruct': 4096, "watsonx/meta-llama/llama-3-70b-instruct": 4096, "watsonx/meta-llama/llama-3-405b-instruct": 16384, "watsonx/ibm/granite-13b-chat-v2": 8191, "watsonx/ibm/granite-34b-code-instruct": 8191, - "watsonx/mistralai/mistral-large": 32768, "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B": 128000, "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": 128000, - "deepinfra/deepseek-ai/DeepSeek-R1": 128000, - "mistral/mistral-small-latest": 8191, - "mistral/mistral-medium-latest": 8191, - "mistral/mistral-large-latest": 128000, - "mistral/open-mistral-7b": 8191, - "mistral/open-mixtral-8x7b": 8191, - "mistral/open-mixtral-8x22b": 8191, - "mistral/codestral-latest": 8191, - "codestral/codestral-latest": 8191, - "codestral/codestral-2405": 8191, 'xiaomi_mimo/mimo-v2.5': 1048576, # 1M, matching the LiteLLM registry for mimo-v2.5, xiaomi_mimo/ is the native LiteLLM Xiaomi provider, but may be limited by config.max_model_tokens 'xiaomi_mimo/mimo-v2.5-pro': 1048576, # 1M, matching the LiteLLM registry for mimo-v2.5-pro, but may be limited by config.max_model_tokens # Provider-prefixed Claude model IDs generated from _CLAUDE_MODEL_FAMILIES diff --git a/tests/unittest/test_get_max_tokens.py b/tests/unittest/test_get_max_tokens.py index 203942bbfb..f0ea03202d 100644 --- a/tests/unittest/test_get_max_tokens.py +++ b/tests/unittest/test_get_max_tokens.py @@ -12,6 +12,17 @@ from pr_agent.algo.utils import MAX_TOKENS, get_max_tokens +def _expected_max_tokens(model: str) -> int: + """Resolve a model's expected max tokens the same way get_max_tokens() does: + prefer the static MAX_TOKENS registry, else fall back to LiteLLM (issue #3196). + Used so tests keep working for models that were intentionally removed from + MAX_TOKENS because LiteLLM's fallback already resolves them correctly. + """ + if model in MAX_TOKENS: + return MAX_TOKENS[model] + return int(litellm.get_model_info(model)["max_input_tokens"]) + + class TestGetMaxTokens: # Test if the file is in MAX_TOKENS @@ -143,7 +154,7 @@ def test_gpt5_thinking_model_max_tokens(self, monkeypatch, model): })() })() monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) - expected = MAX_TOKENS[model.removesuffix("_thinking")] + expected = _expected_max_tokens(model.removesuffix("_thinking")) assert get_max_tokens(model) == expected @pytest.mark.parametrize("model", [ @@ -200,7 +211,7 @@ def test_gpt5_thinking_prefixed_model_max_tokens(self, monkeypatch, model): while tmp.startswith(("openai/", "azure/")): tmp = tmp.removeprefix("openai/").removeprefix("azure/") base = tmp.removesuffix("_thinking") - expected = MAX_TOKENS[base] + expected = _expected_max_tokens(base) assert get_max_tokens(model) == expected def test_non_gpt5_thinking_model_max_tokens_not_stripped(self, monkeypatch): @@ -372,7 +383,7 @@ def test_gpt_codex_models_max_tokens(self, monkeypatch, model): monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) - expected = MAX_TOKENS[model] + expected = _expected_max_tokens(model) assert get_max_tokens(model) == expected @@ -663,7 +674,8 @@ def test_zai_glm_5_2_model_max_tokens(self, monkeypatch, model): monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) - assert get_max_tokens(model) == 200000 + # 1M per LiteLLM (issue #3196): zai/glm-5.2 was understated at 200000. + assert get_max_tokens(model) == 1000000 @pytest.mark.parametrize( "model", @@ -681,7 +693,8 @@ def test_moonshot_kimi_k3_model_max_tokens(self, monkeypatch, model): monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) - assert get_max_tokens(model) == 262144 + # 1M per LiteLLM (issue #3196): moonshot/kimi-k3 was understated at 262144. + assert get_max_tokens(model) == 1048576 @pytest.mark.parametrize( "model", @@ -1097,8 +1110,58 @@ class TestNoLiteLLMDuplicates: "openrouter/x-ai/grok-4.5", "openrouter/x-ai/grok-4.6", "xai/grok-build-latest", + "zai/glm-5.2", } + # The models issue #3196 found pinned more than 10% below LiteLLM's reported + # context window. zai/glm-5.2 is covered by test_zai_glm_5_2_model_max_tokens + # (kept pinned, absent from the bundled cost map); gpt-5.4 / gpt-5.4-2026-03-05 + # are excluded because their 272000 pin is a documented safe default. + AUDITED_UNDERSTATED_MODELS = [ + "mistral/mistral-medium-latest", + "mistral/mistral-small-latest", + "mistral/codestral-latest", + "mistral/open-mixtral-8x22b", + "mistral/mistral-large-latest", + "mistral/open-mistral-7b", + "mistral/open-mixtral-8x7b", + "codestral/codestral-latest", + "codestral/codestral-2405", + "watsonx/mistralai/mistral-large", + "moonshot/kimi-k3", + "deepseek/deepseek-reasoner", + "deepinfra/deepseek-ai/DeepSeek-R1", + "gpt-5", + "gpt-5-2025-08-07", + "gpt-5-nano", + "gpt-5-mini", + "gpt-5.1", + "gpt-5.1-2025-11-13", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "ollama/llama3", + ] + + @pytest.mark.parametrize("model", AUDITED_UNDERSTATED_MODELS) + def test_previously_understated_models_now_resolve_correctly(self, monkeypatch, model): + """Regression guard for issue #3196. + + A static MAX_TOKENS entry takes precedence over the LiteLLM fallback, so a + pin below LiteLLM's max_input_tokens silently shrinks the usable context. + Compare against LiteLLM live rather than a copied number, so a LiteLLM + upgrade that raises a window does not fail this test. + """ + fake_settings = type("", (), { + "config": type("", (), { + "custom_model_max_tokens": 0, + "max_model_tokens": 0 + })() + })() + monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + + litellm_max = int(litellm.get_model_info(model)["max_input_tokens"]) + assert get_max_tokens(model) >= litellm_max + def test_static_max_tokens_has_no_exact_litellm_duplicates(self): """Hardcoded MAX_TOKENS entries must not just mirror LiteLLM. From b00e71f615c0c2d34d6599d915153eaf4aa48680 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Wed, 16 Sep 2026 02:56:37 -0500 Subject: [PATCH 120/238] fix: budget diff packing per model attempt (#3389) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> Co-authored-by: IsmaelMartinez --- pr_agent/algo/pr_processing.py | 401 ++++-- pr_agent/algo/token_budget.py | 104 ++ pr_agent/algo/token_handler.py | 16 +- pr_agent/tools/pr_add_docs.py | 5 +- pr_agent/tools/pr_code_suggestions.py | 56 +- pr_agent/tools/pr_description.py | 21 +- pr_agent/tools/pr_generate_labels.py | 9 +- pr_agent/tools/pr_questions.py | 9 +- pr_agent/tools/pr_reviewer.py | 10 + pr_agent/tools/pr_update_changelog.py | 9 +- tests/unittest/test_attempt_token_budget.py | 192 +++ .../test_compressed_diff_token_sort.py | 28 +- tests/unittest/test_diff_attempt_budget.py | 231 ++++ tests/unittest/test_diff_pipeline_core.py | 68 +- .../test_linked_ticket_token_budget.py | 9 +- .../unittest/test_pr_code_suggestions_core.py | 65 +- tests/unittest/test_pr_description.py | 3 + tests/unittest/test_pr_processing_core.py | 1137 ++++++++++++++++- .../test_review_large_diff_chunking.py | 4 + tests/unittest/test_token_handler.py | 102 ++ .../test_tool_output_reserve_forwarding.py | 65 + 21 files changed, 2361 insertions(+), 183 deletions(-) create mode 100644 pr_agent/algo/token_budget.py create mode 100644 tests/unittest/test_attempt_token_budget.py create mode 100644 tests/unittest/test_diff_attempt_budget.py create mode 100644 tests/unittest/test_tool_output_reserve_forwarding.py diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index 945e0f75f5..9668e96ce1 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -2,7 +2,7 @@ import traceback from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Callable, List, Tuple from pr_agent.algo.git_patch_processing import ( @@ -13,9 +13,10 @@ from pr_agent.algo.language_handler import sort_files_by_main_languages from pr_agent.algo.model_routing import route_primary_model from pr_agent.algo.run_details import record_model_used +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.types import EDIT_TYPE -from pr_agent.algo.utils import ModelType, clip_tokens, get_max_tokens, get_model +from pr_agent.algo.utils import ModelType, clip_tokens, get_model from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.git_providers.git_provider import GitProvider from pr_agent.log import get_logger @@ -40,12 +41,72 @@ def get_effective_fallback_chain() -> tuple[tuple[str, str | None], ...] | None: return _effective_fallback_chain.get() +def _count_raw_and_stripped_tokens(token_handler: TokenHandler, text: str) -> int: + """Count both raw and stripped forms because either representation may be rendered.""" + token_count = token_handler.count_tokens(text) + stripped = text.strip() + if stripped != text: + token_count = max(token_count, token_handler.count_tokens(stripped)) + return token_count + + +def _append_metadata_section( + final_diff: str, + curr_token: int, + section: str, + max_tokens: int, + token_handler: TokenHandler, +) -> tuple[str, int, str]: + """Append one clipped metadata section when its rendered form fits the input budget.""" + if not section: + return final_diff, curr_token, section + + separator = "\n\n" + available_tokens = max_tokens - curr_token + separator_tokens = token_handler.count_tokens(separator) + section_budget = available_tokens - separator_tokens + if section_budget <= 0: + return final_diff, curr_token, "" + + section_tokens = token_handler.count_tokens(section) + clipped_section = clip_tokens(section, section_budget, num_input_tokens=section_tokens) + if not clipped_section: + return final_diff, curr_token, "" + + candidate = final_diff + separator + clipped_section + candidate_tokens = token_handler.prompt_tokens + _count_raw_and_stripped_tokens(token_handler, candidate) + if candidate_tokens <= max_tokens: + return candidate, candidate_tokens, clipped_section + + return final_diff, curr_token, "" + + +def _find_verified_fitting_prefix_length(items, max_length: int, fits: Callable[[list], bool]) -> int: + """Find a fitting ordered prefix with logarithmic exact-count probes. + + Token counts are not assumed to be monotone across concatenated strings. The returned prefix + is always one that ``fits`` verified directly, although a longer fitting prefix may exist. + """ + low = 0 + high = max_length + while low < high: + middle = (low + high + 1) // 2 + if fits(items[:middle]): + low = middle + else: + high = middle - 1 + + if low == 0 and max_length > 0 and fits(items[:1]): + return 1 + return low + + @dataclass class PreparedPRDiff: """The single-call diff and compressed file data prepared for one model attempt. The compressed file data is request-scoped. It is only reused by a caller that keeps the - same token handler and model, so fallback attempts still rebuild their model-specific budget. + same source or bound token handler and model. A different model rebuilds its own counts. """ diff: str @@ -55,6 +116,7 @@ class PreparedPRDiff: model: str | None = None add_line_numbers_to_hunks: bool = False token_handler: TokenHandler | None = None + attempt_budget: AttemptTokenBudget | None = None def cap_and_log_extra_lines(value, direction) -> int: @@ -76,7 +138,18 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, disable_extra_lines: bool = False, large_pr_handling=False, return_remaining_files=False, - return_prepared=False): + return_prepared=False, + output_token_reserve: Callable[[str, int], int] | None = None): + budget = AttemptTokenBudget.for_attempt( + model, token_handler, output_token_reserve=output_token_reserve + ) + token_handler = budget.token_handler + soft_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, preserve_minimum=True, clamp=False + ) + hard_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, preserve_minimum=True, clamp=False + ) if disable_extra_lines: PATCH_EXTRA_LINES_BEFORE = 0 PATCH_EXTRA_LINES_AFTER = 0 @@ -102,21 +175,28 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, patch_extra_lines_before=PATCH_EXTRA_LINES_BEFORE, patch_extra_lines_after=PATCH_EXTRA_LINES_AFTER) # if we are under the limit, return the full diff - if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model): - get_logger().info(f"Tokens: {total_tokens}, total tokens under limit: {get_max_tokens(model)}, " + if total_tokens - token_handler.prompt_tokens < soft_token_budget: + get_logger().info(f"Tokens: {total_tokens}, total tokens under limit: {budget.context_window}, " f"returning full diff.") full_diff = "\n".join(patches_extended) if return_prepared: return PreparedPRDiff(full_diff, [], model=model, add_line_numbers_to_hunks=add_line_numbers_to_hunks, - token_handler=token_handler) + token_handler=token_handler, attempt_budget=budget) return full_diff # if we are over the limit, start pruning (If we got here, we will not extend the patches with extra lines) - get_logger().info(f"Tokens: {total_tokens}, total tokens over limit: {get_max_tokens(model)}, " + get_logger().info(f"Tokens: {total_tokens}, total tokens over limit: {budget.context_window}, " f"pruning diff.") patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list = \ - pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks, large_pr_handling) + pr_generate_compressed_diff( + pr_languages, + token_handler, + soft_token_budget, + hard_token_budget, + add_line_numbers_to_hunks, + large_pr_handling, + ) if large_pr_handling and len(patches_compressed_list) > 1: get_logger().info(f"Large PR handling mode, and found {len(patches_compressed_list)} patches with original diff.") @@ -124,13 +204,12 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, # return the first patch patches_compressed = patches_compressed_list[0] - total_tokens_new = total_tokens_list[0] files_in_patch = files_in_patches_list[0] # Insert additional information about added, modified, and deleted files if there is enough space - max_tokens = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - curr_token = total_tokens_new # == token_handler.count_tokens(final_diff)+token_handler.prompt_tokens + max_tokens = token_handler.prompt_tokens + hard_token_budget final_diff = "\n".join(patches_compressed) + curr_token = token_handler.prompt_tokens + token_handler.count_tokens(final_diff) delta_tokens = 10 added_list_str = modified_list_str = deleted_list_str = "" unprocessed_files = [] @@ -159,17 +238,15 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, deleted_list_str = deleted_list_str + f"\n{filename}" # prune the added, modified, and deleted files lists, and add them to the final diff - added_list_str = clip_tokens(added_list_str, max_tokens - curr_token) - if added_list_str: - final_diff = final_diff + "\n\n" + added_list_str - curr_token += token_handler.count_tokens(added_list_str) + 2 - modified_list_str = clip_tokens(modified_list_str, max_tokens - curr_token) - if modified_list_str: - final_diff = final_diff + "\n\n" + modified_list_str - curr_token += token_handler.count_tokens(modified_list_str) + 2 - deleted_list_str = clip_tokens(deleted_list_str, max_tokens - curr_token) - if deleted_list_str: - final_diff = final_diff + "\n\n" + deleted_list_str + final_diff, curr_token, added_list_str = _append_metadata_section( + final_diff, curr_token, added_list_str, max_tokens, token_handler + ) + final_diff, curr_token, modified_list_str = _append_metadata_section( + final_diff, curr_token, modified_list_str, max_tokens, token_handler + ) + final_diff, curr_token, deleted_list_str = _append_metadata_section( + final_diff, curr_token, deleted_list_str, max_tokens, token_handler + ) get_logger().debug(f"After pruning, added_list_str: {added_list_str}, modified_list_str: {modified_list_str}, " f"deleted_list_str: {deleted_list_str}") @@ -187,6 +264,7 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model=model, add_line_numbers_to_hunks=add_line_numbers_to_hunks, token_handler=token_handler, + attempt_budget=budget, ) if not return_remaining_files: return final_diff @@ -195,7 +273,12 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, def get_pr_diff_multiple_patchs(git_provider: GitProvider, token_handler: TokenHandler, model: str, - add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False): + add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False, + output_token_reserve: Callable[[str, int], int] | None = None): + budget = AttemptTokenBudget.for_attempt( + model, token_handler, output_token_reserve=output_token_reserve + ) + token_handler = budget.token_handler diff_files = git_provider.get_diff_files() # get pr languages @@ -206,71 +289,133 @@ def get_pr_diff_multiple_patchs(git_provider: GitProvider, token_handler: TokenH except Exception: pass + soft_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, preserve_minimum=True, clamp=False + ) + hard_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, preserve_minimum=True, clamp=False + ) patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list = \ - pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks, large_pr_handling=True) + pr_generate_compressed_diff( + pr_languages, + token_handler, + soft_token_budget, + hard_token_budget, + add_line_numbers_to_hunks, + large_pr_handling=True, + ) return patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list def _pack_pr_multi_diffs(file_dict: dict, token_handler: TokenHandler, - model: str, max_calls: int, - return_remaining_files: bool): - patches = [] + return_remaining_files: bool, + token_budget: int): + """Pack diffs additively, then verify each rendered group against the input budget.""" final_diff_list = [] files_in_patches = set() - total_tokens = token_handler.prompt_tokens - call_number = 1 + def count_chunk(candidate_patches): + rendered = "\n".join(candidate_patches) + return _count_raw_and_stripped_tokens(token_handler, rendered) + + def clip_single_patch(filename, patch): + if get_settings().config.get("large_patch_policy", "skip") != "clip": + get_logger().warning(f"Patch too large, skipping: {filename}") + return None + patch_tokens = count_chunk([patch]) + patch_clipped = clip_tokens( + patch, + token_budget, + delete_last_line=True, + num_input_tokens=patch_tokens, + ) + clipped_tokens = count_chunk([patch_clipped]) if patch_clipped else token_budget + 1 + if clipped_tokens > token_budget: + get_logger().warning(f"Patch too large, skipping: {filename}") + return None + get_logger().info(f"Clipped large patch for file: {filename}") + return filename, patch_clipped, clipped_tokens + + packable = [] for filename, data in file_dict.items(): - if call_number > max_calls: - if get_verbosity_level() >= 2: - get_logger().info(f"Reached max calls ({max_calls})") - break - patch = data["patch"] new_patch_tokens = data["tokens"] + if not patch: + continue - if patch and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - if get_settings().config.get("large_patch_policy", "skip") == "skip": - get_logger().warning(f"Patch too large, skipping: {filename}") - continue - if get_settings().config.get("large_patch_policy") == "clip": - delta_tokens = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - token_handler.prompt_tokens - patch_clipped = clip_tokens(patch, delta_tokens, delete_last_line=True, - num_input_tokens=new_patch_tokens) - new_patch_tokens = token_handler.count_tokens(patch_clipped) - if patch_clipped and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - get_logger().warning(f"Patch too large, skipping: {filename}") - continue - get_logger().info(f"Clipped large patch for file: {filename}") - patch = patch_clipped - else: - get_logger().warning(f"Patch too large, skipping: {filename}") + if new_patch_tokens > token_budget: + clipped_item = clip_single_patch(filename, patch) + if not clipped_item: continue + filename, patch, new_patch_tokens = clipped_item + + packable.append((filename, patch, new_patch_tokens)) + + separator_tokens = token_handler.count_tokens("\n") + additive_groups = [] + current_group = [] + current_tokens = 0 + for item in packable: + item_tokens = item[2] + item_cost = item_tokens + (separator_tokens if current_group else 0) + if current_group and current_tokens + item_cost > token_budget: + additive_groups.append(current_group) + current_group = [] + current_tokens = 0 + item_cost = item_tokens + current_group.append(item) + current_tokens += item_cost + if current_group: + additive_groups.append(current_group) + + packed_groups = [] + + def append_group(group): + if not group or len(packed_groups) >= max_calls: + return False + packed_groups.append(group) + files_in_patches.update(filename for filename, _, _ in group) + return True + + for group in additive_groups: + if len(packed_groups) >= max_calls: + break - if patch and (total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD): - final_diff_list.append("\n".join(patches)) - patches = [] - total_tokens = token_handler.prompt_tokens - call_number += 1 - if call_number > max_calls: - if get_verbosity_level() >= 2: - get_logger().info(f"Reached max calls ({max_calls})") + pending_group = group + while pending_group and len(packed_groups) < max_calls: + pending_patches = [patch for _, patch, _ in pending_group] + if count_chunk(pending_patches) <= token_budget: + append_group(pending_group) break - if get_verbosity_level() >= 2: - get_logger().info(f"Call number: {call_number}") - if patch: - patches.append(patch) - files_in_patches.add(filename) - total_tokens += new_patch_tokens - if get_verbosity_level() >= 2: - get_logger().info(f"Tokens: {total_tokens}, last filename: {filename}") + prefix_length = _find_verified_fitting_prefix_length( + pending_group, + len(pending_group) - 1, + lambda prefix: count_chunk([patch for _, patch, _ in prefix]) <= token_budget, + ) + if prefix_length: + append_group(pending_group[:prefix_length]) + pending_group = pending_group[prefix_length:] + continue - if patches: - final_diff_list.append("\n".join(patches).strip()) + item = pending_group[0] + clipped_item = clip_single_patch(item[0], item[1]) + if clipped_item: + append_group([clipped_item]) + pending_group = pending_group[1:] + + packed_all = len(files_in_patches) == len(packable) + for index, group in enumerate(packed_groups): + rendered_chunk = "\n".join(patch for _, patch, _ in group) + if packed_all and index == len(packed_groups) - 1: + rendered_chunk = rendered_chunk.strip() + final_diff_list.append(rendered_chunk) + + if len(files_in_patches) < len(packable) and get_verbosity_level() >= 2: + get_logger().info(f"Reached max calls ({max_calls})") if not return_remaining_files: return final_diff_list @@ -284,9 +429,9 @@ def _pack_pr_multi_diffs(file_dict: dict, def _get_pr_multi_diffs_from_prepared(prepared_diff: PreparedPRDiff, token_handler: TokenHandler, - model: str, max_calls: int, - return_remaining_files: bool): + return_remaining_files: bool, + token_budget: int): """Pack already transformed file patches without repeating preparation work. ``get_pr_diff`` and the review chunking path use the same model-specific token handler and @@ -307,9 +452,9 @@ def _get_pr_multi_diffs_from_prepared(prepared_diff: PreparedPRDiff, return _pack_pr_multi_diffs( file_dict, token_handler, - model, max_calls, return_remaining_files, + token_budget, ) @@ -349,16 +494,20 @@ def pr_generate_extended_diff(pr_languages: list, patch_tokens = token_handler.count_tokens(full_extended_patch) file.tokens = patch_tokens - total_tokens += patch_tokens patches_extended_tokens.append(patch_tokens) patches_extended.append(full_extended_patch) + if patches_extended: + total_tokens += _count_raw_and_stripped_tokens(token_handler, "\n".join(patches_extended)) return patches_extended, total_tokens, patches_extended_tokens -def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, model: str, +def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, + soft_token_budget: int, hard_token_budget: int, convert_hunks_to_line_numbers: bool, - large_pr_handling: bool) -> Tuple[list, list, list, list, dict, list]: + large_pr_handling: bool, + ) -> Tuple[list, list, list, list, dict, list]: + """Prepare patches using the caller's soft and hard diff-only capacities.""" deleted_files_list = [] for lang in top_langs: @@ -398,15 +547,14 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo new_patch_tokens = token_handler.count_tokens(patch) file_dict[file.filename] = {'patch': patch, 'tokens': new_patch_tokens, 'edit_type': file.edit_type} - max_tokens_model = get_max_tokens(model) - # first iteration files_in_patches_list = [] remaining_files_list = [file.filename for file in sorted_files] patches_list =[] total_tokens_list = [] total_tokens, patches, remaining_files_list, files_in_patch_list = generate_full_patch(convert_hunks_to_line_numbers, file_dict, - max_tokens_model, remaining_files_list, token_handler) + soft_token_budget, remaining_files_list, token_handler, + hard_token_budget=hard_token_budget) patches_list.append(patches) total_tokens_list.append(total_tokens) files_in_patches_list.append(files_in_patch_list) @@ -418,8 +566,9 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo if remaining_files_list: total_tokens, patches, remaining_files_list, files_in_patch_list = generate_full_patch(convert_hunks_to_line_numbers, file_dict, - max_tokens_model, - remaining_files_list, token_handler) + soft_token_budget, + remaining_files_list, token_handler, + hard_token_budget=hard_token_budget) if patches: patches_list.append(patches) total_tokens_list.append(total_tokens) @@ -430,19 +579,20 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo return patches_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list -def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_model,remaining_files_list_prev, token_handler): +def generate_full_patch(convert_hunks_to_line_numbers, file_dict, soft_token_budget, remaining_files_list_prev, + token_handler, *, hard_token_budget: int): + """Admit rendered patches using diff-only budgets; return prompt-inclusive totals.""" total_tokens = token_handler.prompt_tokens # initial tokens patches = [] remaining_files_list_new = [] files_in_patch_list = [] + separator_tokens = None for filename, data in file_dict.items(): if filename not in remaining_files_list_prev: continue patch = data['patch'] - - # Hard Stop, no more tokens - if total_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD: + if total_tokens - token_handler.prompt_tokens > hard_token_budget: get_logger().warning(f"File was fully skipped, no more tokens: {filename}.") remaining_files_list_new.append(filename) continue @@ -453,26 +603,45 @@ def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_mod else: patch_final = "\n\n" + patch.strip() new_patch_tokens = token_handler.count_tokens(patch_final) - else: - patch_final = "" - new_patch_tokens = 0 - - # If the patch is too large, leave the file in the remaining-files list. - if total_tokens + new_patch_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - # Current logic is to skip the patch if it's too large - # TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens - # until we meet the requirements - if get_verbosity_level() >= 2: - get_logger().warning(f"Patch too large, skipping it: '{filename}'") - remaining_files_list_new.append(filename) - continue - - if patch: + if patches and separator_tokens is None: + separator_tokens = token_handler.count_tokens("\n") + rendered_patch_tokens = new_patch_tokens + (separator_tokens or 0) + if total_tokens + rendered_patch_tokens > token_handler.prompt_tokens + soft_token_budget: + if get_verbosity_level() >= 2: + get_logger().warning(f"Patch too large, skipping it: '{filename}'") + remaining_files_list_new.append(filename) + continue patches.append(patch_final) - total_tokens += new_patch_tokens files_in_patch_list.append(filename) + total_tokens += rendered_patch_tokens if get_verbosity_level() >= 2: get_logger().info(f"Tokens: {total_tokens}, last filename: {filename}") + + def count_patches(candidate_patches): + return _count_raw_and_stripped_tokens(token_handler, "\n".join(candidate_patches)) + + if patches: + exact_total = token_handler.prompt_tokens + count_patches(patches) + if exact_total - token_handler.prompt_tokens > soft_token_budget: + best_count = _find_verified_fitting_prefix_length( + patches, + len(patches) - 1, + lambda prefix: count_patches(prefix) <= soft_token_budget, + ) + + for filename in files_in_patch_list[best_count:]: + if filename not in remaining_files_list_new: + remaining_files_list_new.append(filename) + patches = patches[:best_count] + files_in_patch_list = files_in_patch_list[:best_count] + total_tokens = ( + token_handler.prompt_tokens + count_patches(patches) + if patches + else token_handler.prompt_tokens + ) + else: + total_tokens = exact_total + return total_tokens, patches, remaining_files_list_new, files_in_patch_list @@ -551,7 +720,8 @@ def get_pr_multi_diffs(git_provider: GitProvider, max_calls: int = 5, add_line_numbers: bool = True, return_remaining_files: bool = False, - prepared_diff: PreparedPRDiff | None = None): + prepared_diff: PreparedPRDiff | None = None, + output_token_reserve: Callable[[str, int], int] | None = None): """ Retrieves the diff files from a Git provider, sorts them by main language, and generates patches for each file. The patches are split into multiple groups based on the maximum number of tokens allowed for the given model. @@ -572,20 +742,37 @@ def get_pr_multi_diffs(git_provider: GitProvider, With `return_remaining_files`, a tuple of that list and the list of omitted file names. """ - if ( + can_reuse_prepared = ( prepared_diff is not None and prepared_diff.file_dict is not None and prepared_diff.model == model and add_line_numbers and prepared_diff.add_line_numbers_to_hunks == add_line_numbers - and prepared_diff.token_handler is token_handler - ): + and ( + prepared_diff.attempt_budget.matches(model, token_handler) + if prepared_diff.attempt_budget is not None + else prepared_diff.token_handler is token_handler + ) + ) + if can_reuse_prepared and prepared_diff.attempt_budget is not None: + # Reuse the model-bound prompt count while honoring this call's reserve policy. + budget = replace(prepared_diff.attempt_budget, output_token_reserve=output_token_reserve) + else: + budget = AttemptTokenBudget.for_attempt( + model, token_handler, output_token_reserve=output_token_reserve + ) + token_handler = budget.token_handler + soft_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, preserve_minimum=True, clamp=False + ) + + if can_reuse_prepared: return _get_pr_multi_diffs_from_prepared( prepared_diff, token_handler, - model, max_calls, return_remaining_files, + soft_token_budget, ) diff_files = git_provider.get_diff_files() @@ -607,7 +794,7 @@ def get_pr_multi_diffs(git_provider: GitProvider, patch_extra_lines_after=PATCH_EXTRA_LINES_AFTER) # if we are under the limit, return the full diff - if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model): + if total_tokens - token_handler.prompt_tokens < soft_token_budget: full_diff_list = ["\n".join(patches_extended)] if patches_extended else [] return (full_diff_list, []) if return_remaining_files else full_diff_list @@ -651,9 +838,9 @@ def get_pr_multi_diffs(git_provider: GitProvider, return _pack_pr_multi_diffs( file_dict, token_handler, - model, max_calls, return_remaining_files, + soft_token_budget, ) diff --git a/pr_agent/algo/token_budget.py b/pr_agent/algo/token_budget.py new file mode 100644 index 0000000000..3d752c0fce --- /dev/null +++ b/pr_agent/algo/token_budget.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from pr_agent.algo.token_handler import TokenHandler +from pr_agent.algo.utils import get_max_tokens +from pr_agent.log import get_logger + + +def _positive_int(value) -> int | None: + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return value + return None + + +@dataclass(frozen=True) +class AttemptTokenBudget: + """Track token accounting and output headroom for one attempted model.""" + + model: str + source_token_handler: object + token_handler: object + context_window: int + output_token_reserve: Callable[[str, int], int] | None = None + + @classmethod + def for_attempt( + cls, + model: str, + token_handler, + *, + output_token_reserve=None, + ignore_max_model_tokens: bool = False, + ) -> AttemptTokenBudget: + """Create an immutable budget whose tokenizer and window belong to ``model``.""" + bound_token_handler = ( + token_handler.for_model(model) + if isinstance(token_handler, TokenHandler) + else token_handler + ) + return cls( + model=model, + source_token_handler=token_handler, + token_handler=bound_token_handler, + context_window=get_max_tokens(model, ignore_max_model_tokens=ignore_max_model_tokens), + output_token_reserve=output_token_reserve, + ) + + @property + def prompt_tokens(self) -> int: + prompt_tokens = getattr(self.token_handler, "prompt_tokens", 0) + if isinstance(prompt_tokens, int) and not isinstance(prompt_tokens, bool) and prompt_tokens >= 0: + return prompt_tokens + return 0 + + def resolve_output_reserve( + self, + default_output_tokens: int, + *, + preserve_minimum: bool = False, + ) -> int: + """Resolve output headroom without flattening consumer-specific minimums.""" + resolved = None + if callable(self.output_token_reserve): + try: + resolved = _positive_int(self.output_token_reserve(self.model, default_output_tokens)) + except Exception as error: + get_logger().debug(f"Failed to resolve the output token reserve for {self.model}: {error}") + + if resolved is None: + resolved = default_output_tokens + if preserve_minimum: + resolved = max(resolved, default_output_tokens) + return resolved + + def available_tokens( + self, + default_output_tokens: int, + *, + preserve_minimum: bool = False, + prompt_tokens: int | None = None, + clamp: bool = True, + ) -> int: + """Return remaining input capacity; raw mode preserves packers' strict hard stops.""" + fixed_prompt_tokens = self.prompt_tokens if prompt_tokens is None else prompt_tokens + output_reserve = self.resolve_output_reserve( + default_output_tokens, preserve_minimum=preserve_minimum, + ) + available = self.context_window - output_reserve - fixed_prompt_tokens + return max(available, 0) if clamp else available + + def count_tokens(self, text: str, *, force_accurate: bool = False) -> int: + """Count text with the tokenizer bound to this attempt.""" + if force_accurate: + return self.token_handler.count_tokens(text, force_accurate=True) + return self.token_handler.count_tokens(text) + + def matches(self, model: str, source_token_handler: object) -> bool: + """Return whether prepared data belongs to the same model and source handler.""" + return self.model == model and ( + source_token_handler is self.source_token_handler + or source_token_handler is self.token_handler + ) diff --git a/pr_agent/algo/token_handler.py b/pr_agent/algo/token_handler.py index 6faa2af88e..cf54a96666 100644 --- a/pr_agent/algo/token_handler.py +++ b/pr_agent/algo/token_handler.py @@ -79,11 +79,23 @@ def __init__(self, pr=None, vars: dict | None = None, system="", user="", model= """ if vars is None: vars = {} - self.encoder = TokenEncoder.get_token_encoder(model) + self.model = model or get_settings().config.model + self.pr = pr + self.vars = vars + self.system = system + self.user = user + self.prompt_tokens = 0 + self.encoder = TokenEncoder.get_token_encoder(self.model) if pr is not None: self.prompt_tokens = self._get_system_user_tokens(pr, self.encoder, vars, system, user) + def for_model(self, model: str): + """Return a handler bound to ``model`` without mutating this handler.""" + if model == self.model: + return self + return TokenHandler(self.pr, self.vars, self.system, self.user, model=model) + def _get_system_user_tokens(self, pr, encoder, vars: dict, system, user): """ Calculates the number of tokens in the system and user strings. @@ -165,7 +177,7 @@ def _get_token_count_by_model_type(self, patch: str, default_estimate: int) -> i Returns: int: The calculated token count. """ - model_name = get_settings().config.model.lower() + model_name = str(getattr(self, "model", None) or get_settings().config.model).lower() if ModelTypeValidator.is_openai_model(model_name) and get_settings(use_context=False).get('openai.key'): return default_estimate diff --git a/pr_agent/tools/pr_add_docs.py b/pr_agent/tools/pr_add_docs.py index ca76ee577b..884b307a3e 100644 --- a/pr_agent/tools/pr_add_docs.py +++ b/pr_agent/tools/pr_add_docs.py @@ -82,7 +82,10 @@ async def _prepare_prediction(self, model: str): self.token_handler, model, add_line_numbers_to_hunks=True, - disable_extra_lines=False) + disable_extra_lines=False, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + )) get_logger().info('Getting AI prediction...') self.prediction = await self._get_prediction(model) diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 19a87c71d1..284ee6fbca 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -27,6 +27,7 @@ from pr_agent.algo.repo_context import build_repo_context from pr_agent.algo.run_details import init_run_details, record_model_used from pr_agent.algo.skills_loader import get_skills_context +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import ( ModelType, @@ -1773,7 +1774,12 @@ async def prepare_prediction_main(self, model: str) -> dict: self.token_handler, model, max_calls=get_settings().pr_code_suggestions.max_number_of_calls, - add_line_numbers=True) # decouple hunk with line numbers + add_line_numbers=True, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), + "get_output_token_reserve", + None, + )) # decouple hunk with line numbers self.patches_diff_list_no_line_numbers = self.remove_line_numbers(self.patches_diff_list) # decouple hunk else: @@ -1782,7 +1788,12 @@ async def prepare_prediction_main(self, model: str) -> dict: self.token_handler, model, max_calls=get_settings().pr_code_suggestions.max_number_of_calls, - add_line_numbers=False) + add_line_numbers=False, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), + "get_output_token_reserve", + None, + )) self.patches_diff_list = await self.convert_to_decoupled_with_line_numbers( self.patches_diff_list_no_line_numbers, model) if not self.patches_diff_list: @@ -1791,7 +1802,12 @@ async def prepare_prediction_main(self, model: str) -> dict: self.token_handler, model, max_calls=get_settings().pr_code_suggestions.max_number_of_calls, - add_line_numbers=True) # decouple hunk with line numbers + add_line_numbers=True, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), + "get_output_token_reserve", + None, + )) # decouple hunk with line numbers self.patches_diff_list_no_line_numbers = self.remove_line_numbers(self.patches_diff_list) if self.patches_diff_list: @@ -1847,6 +1863,17 @@ async def prepare_prediction_main(self, model: str) -> dict: async def convert_to_decoupled_with_line_numbers(self, patches_diff_list_no_line_numbers, model) -> List[str]: with get_logger().contextualize(sub_feature='convert_to_decoupled_with_line_numbers'): try: + attempt_budget = AttemptTokenBudget.for_attempt( + model, + self.token_handler, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ), + ignore_max_model_tokens=True, + ) + max_input_tokens = attempt_budget.available_tokens( + 2_000, preserve_minimum=True + ) patches_diff_list = [] for patch_prompt in patches_diff_list_no_line_numbers: file_prefix = "## File: " @@ -1863,14 +1890,23 @@ async def convert_to_decoupled_with_line_numbers(self, patches_diff_list_no_line file=None).strip() patches_new[i] = patches_new[i].strip() patch_final = "\n\n\n".join(patches_new) - # take the actual max tokens, without any reductions - max_tokens_full = get_max_tokens(model, ignore_max_model_tokens=True) - delta_output = 2000 - token_count = self.token_handler.count_tokens(patch_final) - if token_count > max_tokens_full - delta_output: + token_count = attempt_budget.count_tokens(patch_final) + if token_count > max_input_tokens: get_logger().warning( - f"Token count {token_count} exceeds the limit {max_tokens_full - delta_output}. clipping the tokens") - patch_final = clip_tokens(patch_final, max_tokens_full - delta_output) + f"Token count {token_count} exceeds the limit {max_input_tokens}. clipping the tokens") + add_truncation_marker = True + while patch_final and token_count > max_input_tokens: + clipped_patch = clip_tokens( + patch_final, + max_input_tokens, + add_three_dots=add_truncation_marker, + num_input_tokens=token_count, + ) + add_truncation_marker = False + if len(clipped_patch) >= len(patch_final): + clipped_patch = patch_final[:len(patch_final) // 2] + patch_final = clipped_patch + token_count = attempt_budget.count_tokens(patch_final) patches_diff_list.append(patch_final) return patches_diff_list except Exception: diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index de50f296c8..6ac8632260 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -277,7 +277,20 @@ async def _prepare_prediction(self, model: str) -> None: model, ) large_pr_handling = get_settings().pr_description.get("enable_large_pr_handling", True) and "pr_description_only_files_prompts" in get_settings() - output = get_pr_diff(self.git_provider, self.token_handler, model, large_pr_handling=large_pr_handling, return_remaining_files=True) + output_token_reserve = getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ) + output_token_reserve_kwargs = ( + {"output_token_reserve": output_token_reserve} if callable(output_token_reserve) else {} + ) + output = get_pr_diff( + self.git_provider, + self.token_handler, + model, + large_pr_handling=large_pr_handling, + return_remaining_files=True, + **output_token_reserve_kwargs, + ) if isinstance(output, tuple): patches_diff, remaining_files_list = output else: @@ -310,7 +323,11 @@ async def _prepare_prediction(self, model: str) -> None: ) (patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list) = get_pr_diff_multiple_patchs( - self.git_provider, token_handler_only_files_prompt, model) + self.git_provider, + token_handler_only_files_prompt, + model, + **output_token_reserve_kwargs, + ) # get the files prediction for each patch chunk_pairs = list(zip(patches_compressed_list, files_in_patches_list, strict=True)) diff --git a/pr_agent/tools/pr_generate_labels.py b/pr_agent/tools/pr_generate_labels.py index c6bdea2af4..ce249efec2 100644 --- a/pr_agent/tools/pr_generate_labels.py +++ b/pr_agent/tools/pr_generate_labels.py @@ -129,7 +129,14 @@ async def _prepare_prediction(self, model: str) -> None: self.data = None get_logger().info(f"Getting PR diff {self.pr_id}") - self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) + self.patches_diff = get_pr_diff( + self.git_provider, + self.token_handler, + model, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ), + ) get_logger().info(f"Getting AI prediction {self.pr_id}") prediction = await self._get_prediction(model) data = self._load_valid_labels_yaml(prediction) diff --git a/pr_agent/tools/pr_questions.py b/pr_agent/tools/pr_questions.py index e25b4f6c6e..9bac551482 100644 --- a/pr_agent/tools/pr_questions.py +++ b/pr_agent/tools/pr_questions.py @@ -128,7 +128,14 @@ def identify_image_in_comment(self): return img_path async def _prepare_prediction(self, model: str): - self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) + self.patches_diff = get_pr_diff( + self.git_provider, + self.token_handler, + model, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ), + ) if self.patches_diff: get_logger().debug("PR diff", artifact=self.patches_diff) self.prediction = await self._get_prediction(model) diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index dd5d9a1497..9bc94528d3 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -778,6 +778,11 @@ async def _prepare_prediction(self, model: str) -> None: "disable_extra_lines": False, "return_remaining_files": True, } + output_token_reserve = getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ) + if callable(output_token_reserve): + diff_kwargs["output_token_reserve"] = output_token_reserve if chunking_enabled: diff_kwargs["return_prepared"] = True output = get_pr_diff(self.git_provider, self.token_handler, model, **diff_kwargs) @@ -816,6 +821,11 @@ async def _prepare_chunked_prediction(self, model: str, "add_line_numbers": True, "return_remaining_files": True, } + output_token_reserve = getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ) + if callable(output_token_reserve): + multi_diff_kwargs["output_token_reserve"] = output_token_reserve if prepared_diff is not None: multi_diff_kwargs["prepared_diff"] = prepared_diff patches_diff_list, remaining_files_list = get_pr_multi_diffs( diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index d9126e3d1a..424545d70b 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -123,7 +123,14 @@ async def run(self): self.git_provider.publish_comment(changelog_comment) async def _prepare_prediction(self, model: str): - self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) + self.patches_diff = get_pr_diff( + self.git_provider, + self.token_handler, + model, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ), + ) if self.patches_diff: get_logger().debug("PR diff", artifact=self.patches_diff) self.prediction = await self._get_prediction(model) diff --git a/tests/unittest/test_attempt_token_budget.py b/tests/unittest/test_attempt_token_budget.py new file mode 100644 index 0000000000..9be9ff626f --- /dev/null +++ b/tests/unittest/test_attempt_token_budget.py @@ -0,0 +1,192 @@ +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +import pr_agent.algo.token_budget as token_budget_module +import pr_agent.algo.token_handler as token_handler_module + + +class FakeTokenHandler: + def __init__(self, prompt_tokens=0): + self.prompt_tokens = prompt_tokens + self.counted = [] + + def count_tokens(self, text): + self.counted.append(text) + return len(text) + + +@pytest.fixture +def token_settings(monkeypatch): + settings = SimpleNamespace( + config=SimpleNamespace(model="primary-model"), + get=lambda key, default=None: { + "config.model_token_count_estimate_factor": 0, + "openai.key": "test-key", + "anthropic.key": None, + }.get(key, default), + ) + monkeypatch.setattr(token_handler_module, "get_settings", lambda use_context=True: settings) + monkeypatch.setattr( + token_handler_module.TokenEncoder, + "get_token_encoder", + lambda model=None: SimpleNamespace( + model=model, + encode=lambda text, disallowed_special=(): list(text), + ), + ) + return settings + + +def test_for_attempt_uses_requested_window_and_preserves_fake_handler(monkeypatch): + calls = [] + fake_handler = FakeTokenHandler(prompt_tokens=17) + + def get_window(model, ignore_max_model_tokens=False): + calls.append((model, ignore_max_model_tokens)) + return 12_345 + + monkeypatch.setattr(token_budget_module, "get_max_tokens", get_window) + + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "fallback-model", + fake_handler, + ignore_max_model_tokens=True, + ) + + assert budget.model == "fallback-model" + assert budget.source_token_handler is fake_handler + assert budget.token_handler is fake_handler + assert budget.context_window == 12_345 + assert budget.prompt_tokens == 17 + assert calls == [("fallback-model", True)] + + +def test_for_attempt_binds_real_handler_without_mutating_source(monkeypatch, token_settings): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + variables = {"title": "PR"} + source = token_handler_module.TokenHandler(object(), variables, "system {{ title }}", "user") + + budget = token_budget_module.AttemptTokenBudget.for_attempt("fallback-model", source) + + assert source.model == "primary-model" + assert budget.source_token_handler is source + assert budget.token_handler is not source + assert budget.token_handler.model == "fallback-model" + assert budget.token_handler.vars is variables + assert budget.prompt_tokens == len("system PR") + len("user") + + +def test_reserves_are_resolved_independently_for_each_default(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + calls = [] + + def reserve(model, default): + calls.append((model, default)) + return default + 500 + + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "openrouter/model", + FakeTokenHandler(), + output_token_reserve=reserve, + ) + + assert budget.resolve_output_reserve(1_500, preserve_minimum=True) == 2_000 + assert budget.resolve_output_reserve(1_000, preserve_minimum=True) == 1_500 + assert calls == [("openrouter/model", 1_500), ("openrouter/model", 1_000)] + + +@pytest.mark.parametrize( + ("reported", "preserve_minimum", "expected"), + [ + (100, False, 100), + (100, True, 1_500), + (1_200, True, 1_500), + (1_500, True, 1_500), + (5_000, True, 5_000), + ], +) +def test_reserve_floor_is_an_explicit_consumer_policy( + monkeypatch, reported, preserve_minimum, expected +): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "model", + FakeTokenHandler(), + output_token_reserve=lambda _model, _default: reported, + ) + + assert budget.resolve_output_reserve( + 1_500, + preserve_minimum=preserve_minimum, + ) == expected + + +def test_available_tokens_subtracts_prompt_once_and_clamps(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 1_000) + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "model", + FakeTokenHandler(prompt_tokens=100), + output_token_reserve=lambda _model, _default: 200, + ) + + assert budget.available_tokens(1_500) == 700 + assert budget.available_tokens(1_500, prompt_tokens=0) == 800 + assert budget.available_tokens(1_500, prompt_tokens=900) == 0 + + +@pytest.mark.parametrize( + ("window", "reserve", "prompt", "expected"), + [(1_000, 1_200, 0, -200), (1_000, 900, 200, -100), (1_000, 900, 100, 0), (1_000, 900, 99, 1)], +) +def test_raw_capacity_distinguishes_exhausted_from_exact_boundary(window, reserve, prompt, expected): + handler = FakeTokenHandler(prompt_tokens=prompt) + budget = token_budget_module.AttemptTokenBudget( + "model", handler, handler, window, output_token_reserve=lambda model, default: reserve, + ) + + assert budget.available_tokens(1_500, clamp=False) == expected + assert budget.available_tokens(1_500) == max(expected, 0) + + +def test_frozen_budget_can_refresh_callback_without_rebinding(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + source = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "model", + source, + output_token_reserve=lambda _model, _default: 1_500, + ) + + refreshed = replace( + budget, + output_token_reserve=lambda _model, _default: 5_000, + ) + + assert refreshed is not budget + assert refreshed.source_token_handler is source + assert refreshed.token_handler is budget.token_handler + assert refreshed.resolve_output_reserve(1_500) == 5_000 + + +def test_count_tokens_delegates_without_breaking_simple_fakes(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + source = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget.for_attempt("model", source) + + assert budget.count_tokens("abcd") == 4 + assert source.counted == ["abcd"] + + +def test_matches_accepts_source_or_own_bound_handler_only(monkeypatch, token_settings): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + source = token_handler_module.TokenHandler(object(), {}, "system", "user") + budget = token_budget_module.AttemptTokenBudget.for_attempt("fallback-model", source) + equivalent_handler = source.for_model("fallback-model") + + assert budget.matches("fallback-model", source) + assert budget.matches("fallback-model", budget.token_handler) + assert not budget.matches("different-model", source) + assert not budget.matches("fallback-model", equivalent_handler) + assert not budget.matches("fallback-model", object()) diff --git a/tests/unittest/test_compressed_diff_token_sort.py b/tests/unittest/test_compressed_diff_token_sort.py index 201fef1775..c83b5ed062 100644 --- a/tests/unittest/test_compressed_diff_token_sort.py +++ b/tests/unittest/test_compressed_diff_token_sort.py @@ -22,8 +22,14 @@ def test_token_counts_are_filled_in_before_sorting(): small, large = _file("small.py", 1), _file("large.py", 40) assert small.tokens == -1 and large.tokens == -1 - pr_generate_compressed_diff([{"language": "Python", "files": [small, large]}], - FakeTokenHandler(), "gpt-4", False, False) + pr_generate_compressed_diff( + [{"language": "Python", "files": [small, large]}], + FakeTokenHandler(), + 10_000, + 10_000, + False, + False, + ) assert small.tokens > 0 assert large.tokens > small.tokens @@ -34,7 +40,13 @@ def test_files_are_ordered_largest_first(): small, large = _file("small.py", 1), _file("large.py", 40) _, _, _, _, file_dict, _ = pr_generate_compressed_diff( - [{"language": "Python", "files": [small, large]}], FakeTokenHandler(), "gpt-4", False, False) + [{"language": "Python", "files": [small, large]}], + FakeTokenHandler(), + 10_000, + 10_000, + False, + False, + ) assert list(file_dict) == ["large.py", "small.py"] @@ -44,7 +56,13 @@ def test_precomputed_token_counts_are_left_alone(): f = _file("a.py", 5) f.tokens = 12345 - pr_generate_compressed_diff([{"language": "Python", "files": [f]}], - FakeTokenHandler(), "gpt-4", False, False) + pr_generate_compressed_diff( + [{"language": "Python", "files": [f]}], + FakeTokenHandler(), + 10_000, + 10_000, + False, + False, + ) assert f.tokens == 12345 diff --git a/tests/unittest/test_diff_attempt_budget.py b/tests/unittest/test_diff_attempt_budget.py new file mode 100644 index 0000000000..f1cb6ac5f1 --- /dev/null +++ b/tests/unittest/test_diff_attempt_budget.py @@ -0,0 +1,231 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import pr_agent.algo.pr_processing as pr_processing +import pr_agent.algo.token_budget as token_budget_module +from pr_agent.algo.token_handler import TokenEncoder, TokenHandler +from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo +from pr_agent.algo.utils import ModelType +from pr_agent.config_loader import get_settings +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + + +class TaggedEncoder: + def __init__(self, model, events): + self.model = model + self.events = events + + def encode(self, text, disallowed_special=()): + self.events.append((self.model, text)) + return list(range(len(text.split()) * (2 if self.model == "fallback-model" else 1))) + + +class Provider: + def __init__(self): + self.diff_calls = 0 + + def get_diff_files(self): + self.diff_calls += 1 + return [ + FilePatchInfo("old\n", "new\n", "@@ -1 +1 @@\n-old\n+" + "new " * 80, + f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ] + + def get_languages(self): + return {"Python": 100} + + +@pytest.fixture +def attempt_context(monkeypatch): + values = { + "config.model": "primary-model", + "config.model_weak": "weak-model", + "config.fallback_models": [], + "config.patch_extra_lines_before": 0, + "config.patch_extra_lines_after": 0, + "config.large_patch_policy": "skip", + "openai.deployment_id": None, + "openai.fallback_deployments": [], + } + snapshot = snapshot_settings(values) + for key, value in values.items(): + get_settings().set(key, value) + events = [] + monkeypatch.setattr(TokenEncoder, "_encoder_instance", None) + monkeypatch.setattr(TokenEncoder, "_model", None) + monkeypatch.setattr(TokenEncoder, "_create_encoder", staticmethod(lambda model: TaggedEncoder(model, events))) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda _languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda model, **kwargs: 2_000) + handler = TokenHandler(SimpleNamespace(title="PR"), {"title": "PR"}, "System {{ title }}", "User {{ title }}") + events.clear() + yield handler, events + restore_settings(snapshot) + + +@pytest.mark.parametrize("name", ["get_pr_diff", "get_pr_diff_multiple_patchs", "get_pr_multi_diffs"]) +def test_public_packers_bind_the_attempt_before_prompt_and_diff_counting(monkeypatch, attempt_context, name): + source, events = attempt_context + windows = [] + reserves = [] + + def window(model, **kwargs): + windows.append(model) + return 2_000 + + def reserve(model, default): + reserves.append((model, default)) + return default + 100 + + monkeypatch.setattr(token_budget_module, "get_max_tokens", window) + getattr(pr_processing, name)(Provider(), source, "fallback-model", output_token_reserve=reserve) + + assert windows == ["fallback-model"] + assert reserves == [("fallback-model", 1_500)] + ( + [] if name == "get_pr_multi_diffs" else [("fallback-model", 1_000)] + ) + assert {model for model, text in events} == {"fallback-model"} + assert any(text == "System PR" for model, text in events) + assert any("+new" in text for model, text in events) + assert source.model == get_settings().config.model == "primary-model" + assert TokenEncoder._model == "primary-model" + + +@pytest.mark.parametrize("name", ["get_pr_diff", "get_pr_diff_multiple_patchs"]) +def test_compressed_budgets_keep_default_plus_reasoning_reserves(monkeypatch, attempt_context, name): + source, _ = attempt_context + seen = [] + original = pr_processing.pr_generate_compressed_diff + + def compressed(languages, handler, soft, hard, *args, **kwargs): + seen.append((handler.model, soft, hard)) + return original(languages, handler, soft, hard, *args, **kwargs) + + monkeypatch.setattr(pr_processing, "pr_generate_compressed_diff", compressed) + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda model, **kwargs: 2_400) + + getattr(pr_processing, name)( + Provider(), source, "fallback-model", output_token_reserve=lambda model, default: default + 500, + ) + + # Compare against the bound fallback prompt's eight tokens, not the primary model's four. + assert seen == [("fallback-model", 392, 892)] + + +@pytest.mark.parametrize("name", ["get_pr_diff", "get_pr_diff_multiple_patchs"]) +def test_negative_hard_capacity_stops_before_admitting_the_first_patch(attempt_context, name): + source, _ = attempt_context + + # Preserve the stricter hard stop for both valid callback values, even when + # the handler's two policies leave positive soft but negative hard room. + result = getattr(pr_processing, name)( + Provider(), source, "fallback-model", + output_token_reserve=lambda model, default: 3_000 if default == 1_000 else 1_500, + ) + + if name == "get_pr_diff": + assert result == "" + else: + assert result[0] == [[]] + assert set(result[3]) == {f"file_{index}.py" for index in range(4)} + + +@pytest.mark.parametrize("handler_form", ["source", "bound"]) +@pytest.mark.parametrize("reserve", [1_500, 1_800]) +def test_prepared_fallback_reuses_bound_counts_and_matches_fresh_packing(attempt_context, handler_form, reserve): + source, events = attempt_context + provider = Provider() + prepared = pr_processing.get_pr_diff( + provider, source, "fallback-model", add_line_numbers_to_hunks=True, return_prepared=True, + ) + assert prepared.file_dict + handler = source if handler_form == "source" else prepared.attempt_budget.token_handler + events.clear() + + chunks = pr_processing.get_pr_multi_diffs( + provider, handler, "fallback-model", prepared_diff=prepared, + output_token_reserve=lambda model, default: reserve, + ) + + assert provider.diff_calls == 1 + assert not any(text == "System PR" for model, text in events) + assert {model for model, text in events} == {"fallback-model"} + fresh = pr_processing.get_pr_multi_diffs( + Provider(), source, "fallback-model", output_token_reserve=lambda model, default: reserve, + ) + assert chunks == fresh + assert all(8 + prepared.token_handler.count_tokens(chunk) + reserve <= 2_000 for chunk in chunks) + + +@pytest.mark.parametrize("reuse", [False, True]) +def test_multi_packing_preserves_negative_capacity_and_remaining_files(monkeypatch, attempt_context, reuse): + source, _ = attempt_context + provider = Provider() + prepared = pr_processing.get_pr_diff( + provider, source, "fallback-model", add_line_numbers_to_hunks=True, return_prepared=True, + ) if reuse else None + capacities = [] + original = pr_processing._pack_pr_multi_diffs + + def pack(*args): + capacities.append(args[-1]) + return original(*args) + + monkeypatch.setattr(pr_processing, "_pack_pr_multi_diffs", pack) + chunks, remaining = pr_processing.get_pr_multi_diffs( + provider, source, "fallback-model", prepared_diff=prepared, return_remaining_files=True, + output_token_reserve=lambda model, default: 2_500, + ) + + assert capacities == [-508] + assert chunks == [] + assert set(remaining) == {f"file_{index}.py" for index in range(4)} + assert provider.diff_calls == 1 + + +@pytest.mark.parametrize("changed", ["model", "handler", "mode"]) +def test_incompatible_prepared_data_is_rebuilt(attempt_context, changed): + source, events = attempt_context + provider = Provider() + prepared = pr_processing.get_pr_diff( + provider, source, "fallback-model", add_line_numbers_to_hunks=True, return_prepared=True, + ) + handler = source.for_model("fallback-model") if changed == "handler" else source + model = "weak-model" if changed == "model" else "fallback-model" + events.clear() + + pr_processing.get_pr_multi_diffs( + provider, handler, model, prepared_diff=prepared, add_line_numbers=changed != "mode", + ) + + assert provider.diff_calls == 2 + assert {tag for tag, text in events} == {model} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("weak", [False, True]) +async def test_routed_weak_and_fallback_attempts_pack_with_their_own_tokenizer(monkeypatch, attempt_context, weak): + source, events = attempt_context + get_settings().set("config.fallback_models", ["fallback-model"]) + monkeypatch.setattr(pr_processing, "route_primary_model", lambda *args: None if weak else ("routed-model", None)) + monkeypatch.setattr(pr_processing, "record_model_used", lambda *args, **kwargs: None) + attempts = [] + + async def attempt(model): + events.clear() + pr_processing.get_pr_diff(Provider(), source, model) + attempts.append((model, {tag for tag, text in events})) + if model != "fallback-model": + raise RuntimeError("try fallback") + return "ok" + + result = await pr_processing.retry_with_fallback_models( + attempt, model_type=ModelType.WEAK if weak else ModelType.REGULAR, git_provider=MagicMock(), + ) + + primary = "weak-model" if weak else "routed-model" + assert result == "ok" + assert attempts == [(primary, {primary}), ("fallback-model", {"fallback-model"})] + assert source.model == get_settings().config.model == "primary-model" diff --git a/tests/unittest/test_diff_pipeline_core.py b/tests/unittest/test_diff_pipeline_core.py index d0ac044f46..760a5afd3e 100644 --- a/tests/unittest/test_diff_pipeline_core.py +++ b/tests/unittest/test_diff_pipeline_core.py @@ -263,8 +263,7 @@ def test_string_line_params_are_coerced_to_int(self): class TestGenerateFullPatch: - def test_files_within_budget_are_all_included(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_files_within_budget_are_all_included(self): token_handler = FakeTokenHandler(prompt_tokens=10) file_dict = { "a.py": {"patch": "+ change a", "tokens": 5, "edit_type": EDIT_TYPE.MODIFIED}, @@ -273,9 +272,10 @@ def test_files_within_budget_are_all_included(self, monkeypatch): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=10_000, + soft_token_budget=10_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=10_000 - 1_000 - token_handler.prompt_tokens, ) assert files_in == ["a.py", "b.py"] assert remaining == [] @@ -294,9 +294,10 @@ def test_oversized_patch_is_deferred_to_remaining_list(self): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=4_000, # SOFT=1500, HARD=1000 + soft_token_budget=4_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=4_000 - 1_000 - token_handler.prompt_tokens, ) assert "small.py" in files_in assert "huge.py" not in files_in @@ -332,9 +333,18 @@ def test_file_wrapper_is_counted_before_soft_budget_admission(self, filename): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=max_tokens_model, + soft_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD + - token_handler.prompt_tokens + ), remaining_files_list_prev=[filename], token_handler=token_handler, + hard_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD + - token_handler.prompt_tokens + ), ) assert token_handler.prompt_tokens + file_dict[filename]["tokens"] <= ( @@ -354,9 +364,10 @@ def test_remaining_files_list_prev_filters_input(self): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=10_000, + soft_token_budget=10_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=["b.py"], # only b.py is eligible this round token_handler=token_handler, + hard_token_budget=10_000 - 1_000 - token_handler.prompt_tokens, ) assert files_in == ["b.py"] assert remaining == [] @@ -370,9 +381,10 @@ def test_line_numbered_mode_omits_extra_file_header(self): total, patches, _, _ = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=True, file_dict=file_dict, - max_tokens_model=10_000, + soft_token_budget=10_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=["a.py"], token_handler=token_handler, + hard_token_budget=10_000 - 1_000 - token_handler.prompt_tokens, ) # In line-numbered mode, the function does not wrap with another header. assert patches[0].count("## File: 'a.py'") == 1 @@ -389,8 +401,7 @@ def _settings(self): from pr_agent.config_loader import get_settings return get_settings() - def test_deleted_files_collected_and_excluded_from_patches(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_deleted_files_collected_and_excluded_from_patches(self): deleted = FilePatchInfo( base_file="old content", @@ -408,7 +419,8 @@ def test_deleted_files_collected_and_excluded_from_patches(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10), - model="some-model", + soft_token_budget=10_000 - 1_500 - 10, + hard_token_budget=10_000 - 1_000 - 10, convert_hunks_to_line_numbers=False, large_pr_handling=False, ) @@ -422,7 +434,7 @@ def test_deleted_files_collected_and_excluded_from_patches(self, monkeypatch): assert len(patches_list) == 1 assert len(total_tokens_list) == 1 - def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): + def test_large_pr_handling_paginates_across_iterations(self): # Build patches large enough that exactly one fits per iteration. The # per-iteration budget in generate_full_patch is # max_tokens_model - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - prompt_tokens @@ -439,7 +451,6 @@ def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): # prompt + patch_tokens <= max - SOFT # prompt + 2 * patch_tokens > max - SOFT max_tokens = soft_threshold + prompt_tokens + patch_tokens + 1 - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: max_tokens) settings = self._settings() original_max_ai_calls = settings.pr_description.max_ai_calls @@ -459,7 +470,10 @@ def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=token_handler, - model="some-model", + soft_token_budget=max_tokens - soft_threshold - prompt_tokens, + hard_token_budget=( + max_tokens - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - prompt_tokens + ), convert_hunks_to_line_numbers=False, large_pr_handling=True, ) @@ -473,7 +487,7 @@ def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): finally: settings.pr_description.max_ai_calls = original_max_ai_calls - def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, monkeypatch): + def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self): prompt_tokens = 100 token_handler = Utf8TokenHandler(prompt_tokens=prompt_tokens) patch_str = "@@ -1 +1 @@\n+" + "value " * 100 @@ -491,7 +505,6 @@ def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, m + rendered_patch_tokens + bare_patch_tokens ) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: max_tokens) settings = self._settings() original_max_ai_calls = settings.pr_description.max_ai_calls settings.pr_description.max_ai_calls = 3 @@ -506,7 +519,12 @@ def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, m file_dict, files_in_patches_list) = pr_processing.pr_generate_compressed_diff( top_langs=[{"files": files}], token_handler=token_handler, - model="some-model", + soft_token_budget=( + max_tokens - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - prompt_tokens + ), + hard_token_budget=( + max_tokens - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - prompt_tokens + ), convert_hunks_to_line_numbers=False, large_pr_handling=True, ) @@ -526,8 +544,7 @@ def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, m finally: settings.pr_description.max_ai_calls = original_max_ai_calls - def test_files_with_empty_patch_are_skipped(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_files_with_empty_patch_are_skipped(self): empty = _make_file(filename="empty.py", patch="", tokens=0) kept = _make_file(filename="kept.py", tokens=5) @@ -538,7 +555,8 @@ def test_files_with_empty_patch_are_skipped(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10), - model="some-model", + soft_token_budget=10_000 - 1_500 - 10, + hard_token_budget=10_000 - 1_000 - 10, convert_hunks_to_line_numbers=False, large_pr_handling=False, ) @@ -548,8 +566,7 @@ def test_files_with_empty_patch_are_skipped(self, monkeypatch): assert "kept.py" in file_dict assert files_in_patches_list[0] == ["kept.py"] - def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self): kept = _make_file(filename="kept.py", tokens=5) top_langs = [{"files": [kept]}] @@ -557,7 +574,8 @@ def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self, monkeypatch) pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10), - model="some-model", + soft_token_budget=10_000 - 1_500 - 10, + hard_token_budget=10_000 - 1_000 - 10, convert_hunks_to_line_numbers=True, large_pr_handling=False, ) @@ -565,10 +583,9 @@ def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self, monkeypatch) assert "__new hunk__" in file_dict["kept.py"]["patch"] assert files_in_patches_list[0] == ["kept.py"] - def test_max_ai_calls_boundary_caps_iterations(self, monkeypatch): + def test_max_ai_calls_boundary_caps_iterations(self): # Force every patch to be too big to fit so each iteration defers # everything to the next round; this isolates the iteration cap. - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1_000) settings = self._settings() original_max_ai_calls = settings.pr_description.max_ai_calls settings.pr_description.max_ai_calls = 2 # allow 1 extra loop iteration (range(0)) @@ -583,7 +600,8 @@ def test_max_ai_calls_boundary_caps_iterations(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10_000), - model="some-model", + soft_token_budget=1_000 - 1_500 - 10_000, + hard_token_budget=1_000 - 1_000 - 10_000, convert_hunks_to_line_numbers=False, large_pr_handling=True, ) diff --git a/tests/unittest/test_linked_ticket_token_budget.py b/tests/unittest/test_linked_ticket_token_budget.py index a54acb7647..40fb60553e 100644 --- a/tests/unittest/test_linked_ticket_token_budget.py +++ b/tests/unittest/test_linked_ticket_token_budget.py @@ -121,7 +121,14 @@ def test_oversized_ticket_payload_keeps_diff_budget_and_raw_cache(monkeypatch): } } - result = generate_full_patch(True, file_dict, max_tokens, ["src/app.py"], handler) + result = generate_full_patch( + True, + file_dict, + max_tokens - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - handler.prompt_tokens, + ["src/app.py"], + handler, + hard_token_budget=max_tokens - 1_000 - handler.prompt_tokens, + ) assert result[1] == [f"\n\n{patch}"] assert result[2] == [] diff --git a/tests/unittest/test_pr_code_suggestions_core.py b/tests/unittest/test_pr_code_suggestions_core.py index 093c846092..8e08996c4c 100644 --- a/tests/unittest/test_pr_code_suggestions_core.py +++ b/tests/unittest/test_pr_code_suggestions_core.py @@ -4,8 +4,10 @@ import pytest +import pr_agent.algo.token_budget as token_budget_module import pr_agent.tools.pr_code_suggestions as pr_code_suggestions_module from pr_agent.algo.pr_processing import pr_generate_extended_diff, retry_with_fallback_models +from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.types import FilePatchInfo from pr_agent.algo.utils import PRCodeSuggestionsHeader, PRCodeSuggestionsIdentity, load_large_diff from pr_agent.config_loader import get_settings @@ -18,6 +20,7 @@ def _make_tool(git_provider=None): tool = PRCodeSuggestions.__new__(PRCodeSuggestions) tool.git_provider = git_provider or MagicMock() + tool.ai_handler = MagicMock() tool.progress_response = None return tool @@ -156,6 +159,49 @@ async def test_convert_to_decoupled_preserves_quoted_file_headings_across_files( assert "2 +new first" not in second_section +@pytest.mark.asyncio +async def test_convert_to_decoupled_uses_fallback_model_budget_and_tokenizer(monkeypatch): + counted_models = [] + reserve_calls = [] + window_calls = [] + + class ModelBoundTokenHandler(TokenHandler): + def __init__(self, model="primary-model"): + super().__init__(model=model) + self.prompt_tokens = 10 if model == "fallback-model" else 0 + + def for_model(self, model): + return ModelBoundTokenHandler(model) + + def count_tokens(self, text, force_accurate=False): + counted_models.append(self.model) + return len(text) if self.model == "fallback-model" else 1 + + def get_window(model, ignore_max_model_tokens=False): + window_calls.append((model, ignore_max_model_tokens)) + return 2_100 + + def get_output_token_reserve(model, default): + reserve_calls.append((model, default)) + return default + + monkeypatch.setattr(token_budget_module, "get_max_tokens", get_window) + tool = _make_tool() + tool.token_handler = ModelBoundTokenHandler() + tool.ai_handler = SimpleNamespace(get_output_token_reserve=get_output_token_reserve) + patch_prompt = "## File: 'app.py'\n\n@@ -1 +1 @@\n-old\n+" + "replacement " * 40 + + result = await tool.convert_to_decoupled_with_line_numbers([patch_prompt], "fallback-model") + + assert len(result) == 1 + assert result[0] + assert len(result[0]) <= 90 + assert "replacement " * 40 not in result[0] + assert counted_models and set(counted_models) == {"fallback-model"} + assert reserve_calls == [("fallback-model", 2_000)] + assert window_calls == [("fallback-model", True)] + + @pytest.mark.asyncio async def test_prepare_prediction_main_caps_suggestions_per_file_after_chunk_merge(): settings_snapshot = snapshot_settings(( @@ -177,7 +223,9 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) )]} try: - with patch.object(pr_code_suggestions_module, "get_pr_multi_diffs", return_value=["chunk-a", "chunk-b"]): + with patch.object( + pr_code_suggestions_module, "get_pr_multi_diffs", return_value=["chunk-a", "chunk-b"] + ) as get_pr_multi_diffs: tool._get_prediction = fake_get_prediction data = await tool.prepare_prediction_main("primary-model") @@ -188,6 +236,7 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) one_sentence_summary="Finding from chunk-a", relevant_lines_start=1, )] + assert get_pr_multi_diffs.call_args.kwargs["output_token_reserve"] is tool.ai_handler.get_output_token_reserve def test_limit_suggestions_per_file_keeps_highest_scores_and_preserves_other_files(): @@ -445,10 +494,11 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) return {"code_suggestions": [_valid_suggestion(relevant_file=f"chunk-{len(chunk_pairs)}.py")]} try: - with patch.object(pr_code_suggestions_module, "get_pr_multi_diffs", side_effect=[ - ["stale unnumbered chunk"], - ["1 fallback-a", "2 fallback-b"], - ]): + with patch.object( + pr_code_suggestions_module, + "get_pr_multi_diffs", + side_effect=[["stale unnumbered chunk"], ["1 fallback-a", "2 fallback-b"]], + ) as get_pr_multi_diffs: tool._get_prediction = fake_get_prediction data = await tool.prepare_prediction_main("primary-model") @@ -462,6 +512,11 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) ] assert tool.total_chunk_count == 2 assert len(data["code_suggestions"]) == 2 + assert len(get_pr_multi_diffs.call_args_list) == 2 + assert all( + call.kwargs["output_token_reserve"] is tool.ai_handler.get_output_token_reserve + for call in get_pr_multi_diffs.call_args_list + ) def test_suggestions_coverage_footer_reports_partial_runs_and_respects_flag(): diff --git a/tests/unittest/test_pr_description.py b/tests/unittest/test_pr_description.py index 612d3e6218..09e4d6fc15 100644 --- a/tests/unittest/test_pr_description.py +++ b/tests/unittest/test_pr_description.py @@ -47,6 +47,7 @@ def _make_large_pr_instance(diff_files=None): FilePatchInfo("", "", "", "src/file1.py"), FilePatchInfo("", "", "", "src/file2.py"), ] + obj.ai_handler = MagicMock() obj.token_handler = MagicMock() obj.vars = { "title": "Test PR", @@ -576,9 +577,11 @@ async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt _, diff_kwargs = mock_diff.call_args assert diff_kwargs.get("large_pr_handling") is True assert diff_kwargs.get("return_remaining_files") is True + assert diff_kwargs.get("output_token_reserve") is obj.ai_handler.get_output_token_reserve # Verify get_pr_diff_multiple_patchs was invoked mock_multi.assert_called_once() + assert mock_multi.call_args.kwargs["output_token_reserve"] is obj.ai_handler.get_output_token_reserve # Verify calls to prompts prompts_called = [p for p, _ in recorded_prompts] diff --git a/tests/unittest/test_pr_processing_core.py b/tests/unittest/test_pr_processing_core.py index 0c29243d19..59f0aacb8f 100644 --- a/tests/unittest/test_pr_processing_core.py +++ b/tests/unittest/test_pr_processing_core.py @@ -4,6 +4,7 @@ import pytest import pr_agent.algo.pr_processing as pr_processing +import pr_agent.algo.token_budget as token_budget from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.algo.utils import ModelType from pr_agent.config_loader import get_settings @@ -35,6 +36,955 @@ def get_languages(self): return {"Python": 100} +class CharacterTokenHandler(FakeTokenHandler): + def count_tokens(self, patch): + self.count_calls += 1 + return len(patch) + + +class NonMonotoneTokenHandler(FakeTokenHandler): + def count_tokens(self, patch): + self.count_calls += 1 + letters = "".join( + line for line in patch.splitlines() if line in {"A", "B", "C", "D", "E"} + ) + prefix_counts = { + "A": 1, + "AB": 6, + "ABC": 4, + "ABCD": 4, + "ABCDE": 6, + "BCD": 3, + "BCDE": 6, + } + return prefix_counts.get(letters, len(letters)) + + +def _rendered_budget_files(): + lines = [ + 'F6[SZCg 3utmp{/(o8HXGWIwSROm2l(ULv"2d":{', + "E'57U/iCFe V9\\'2t:_JBD=d4W{il2T'zdAMWM)]", + ] + return [ + FilePatchInfo("old\n", lines[i % 2] + "\n", + "@@ -1 +1 @@\n-old\n+" + lines[i % 2] + "\n", + f"file_{i}.py", edit_type=EDIT_TYPE.MODIFIED) + for i in range(4) + ] + + +@pytest.mark.parametrize("add_line_numbers", [False, True]) +def test_extended_diff_total_counts_the_rendered_join(add_line_numbers): + handler = CharacterTokenHandler(prompt_tokens=11) + patches, total, per_patch = pr_processing.pr_generate_extended_diff( + [{"files": _rendered_budget_files()}], handler, add_line_numbers, + ) + + assert per_patch == [handler.count_tokens(patch) for patch in patches] + assert total == handler.prompt_tokens + handler.count_tokens("\n".join(patches)) + + +@pytest.mark.parametrize("packing_path", ["single", "multi"]) +@pytest.mark.parametrize("add_line_numbers", [False, True]) +def test_fast_path_rejects_a_join_that_exceeds_the_reserved_limit( + monkeypatch, packing_path, add_line_numbers, +): + handler = CharacterTokenHandler(prompt_tokens=11) + patches, _, individual_counts = pr_processing.pr_generate_extended_diff( + [{"files": _rendered_budget_files()}], handler, add_line_numbers, + ) + reserve = 5_000 + limit = handler.prompt_tokens + sum(individual_counts) + reserve + 1 + assert handler.prompt_tokens + handler.count_tokens("\n".join(patches)) + reserve > limit + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda langs, files: [{"files": files}]) + monkeypatch.setattr(pr_processing, "extend_patch", lambda original, patch, *args, **kwargs: patch) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: limit) + monkeypatch.setattr( + pr_processing, "pr_generate_compressed_diff", + lambda *args, **kwargs: ([["compressed"]], [12], [], [], {}, [[]]), + ) + monkeypatch.setattr(pr_processing, "_pack_pr_multi_diffs", lambda *args: ["compressed"]) + + if packing_path == "single": + result = pr_processing.get_pr_diff( + FakeProvider(_rendered_budget_files()), handler, "model", + add_line_numbers_to_hunks=add_line_numbers, + output_token_reserve=lambda model, default: reserve, + ) + assert result == "compressed" + else: + result = pr_processing.get_pr_multi_diffs( + FakeProvider(_rendered_budget_files()), handler, "model", + add_line_numbers=add_line_numbers, + output_token_reserve=lambda model, default: reserve, + ) + assert result == ["compressed"] + + +def test_real_encoder_extended_diff_total_includes_non_additive_join(monkeypatch): + import tiktoken + + encoder = tiktoken.get_encoding("o200k_base") + + class EncoderHandler(FakeTokenHandler): + def count_tokens(self, patch): + return len(encoder.encode(patch)) + + handler = EncoderHandler(prompt_tokens=11) + patches, total, per_patch = pr_processing.pr_generate_extended_diff( + [{"files": _rendered_budget_files()}], handler, False, + ) + rendered_count = handler.count_tokens("\n".join(patches)) + assert rendered_count > sum(per_patch) + assert total == handler.prompt_tokens + rendered_count + + reserve = 1_500 + limit = handler.prompt_tokens + sum(per_patch) + reserve + file_dict = { + file.filename: {"patch": file.patch, "tokens": handler.count_tokens(file.patch), + "edit_type": file.edit_type} + for file in _rendered_budget_files() + } + _, compressed, remaining, _ = pr_processing.generate_full_patch( + False, + file_dict, + limit - reserve - handler.prompt_tokens, + list(file_dict), + handler, + hard_token_budget=limit - 1_000 - handler.prompt_tokens, + ) + assert remaining + assert handler.prompt_tokens + handler.count_tokens("\n".join(compressed)) + reserve <= limit + + transformed = { + name: {"patch": patch, "tokens": tokens} + for name, patch, tokens in zip(file_dict, patches, per_patch, strict=True) + } + chunks = pr_processing._pack_pr_multi_diffs( + transformed, handler, 5, False, limit - reserve - handler.prompt_tokens, + ) + assert len(chunks) > 1 + assert all(handler.prompt_tokens + handler.count_tokens(chunk) + reserve <= limit for chunk in chunks) + + +@pytest.mark.parametrize("convert_line_numbers", [False, True]) +@pytest.mark.parametrize("overflow", [0, 1]) +def test_compressed_packing_counts_join_before_admitting_file(convert_line_numbers, overflow): + handler = CharacterTokenHandler(prompt_tokens=11) + file_dict = { + name: {"patch": "+ alpha", "tokens": 7, "edit_type": EDIT_TYPE.MODIFIED} + for name in ["a.py", "b.py"] + } + rendered = [ + "\n\n+ alpha" if convert_line_numbers else f"\n\n## File: '{name}'\n\n+ alpha\n" + for name in file_dict + ] + reserve = 1_500 + limit = handler.prompt_tokens + handler.count_tokens("\n".join(rendered)) + reserve - overflow + + total, patches, remaining, included = pr_processing.generate_full_patch( + convert_line_numbers, + file_dict, + limit - reserve - handler.prompt_tokens, + list(file_dict), + handler, + hard_token_budget=limit - 1_000 - handler.prompt_tokens, + ) + + assert total == handler.prompt_tokens + handler.count_tokens("\n".join(patches)) + assert total + reserve <= limit + assert included == (["a.py", "b.py"] if overflow == 0 else ["a.py"]) + assert remaining == ([] if overflow == 0 else ["b.py"]) + + +@pytest.mark.parametrize("overflow", [0, 1]) +@pytest.mark.parametrize("max_calls", [1, 2]) +def test_multi_packing_recounts_non_additive_join_and_preserves_remaining( + monkeypatch, overflow, max_calls, +): + class NonAdditiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return len(patch) + (3 if "\n" in patch else 0) + + handler = NonAdditiveHandler(prompt_tokens=11) + reserve = 1_500 + limit = handler.prompt_tokens + handler.count_tokens("A\nB") + reserve - overflow + file_dict = {name: {"patch": patch, "tokens": 1} for name, patch in [("a.py", "A"), ("b.py", "B")]} + + chunks, remaining = pr_processing._pack_pr_multi_diffs( + file_dict, handler, max_calls, True, limit - reserve - handler.prompt_tokens, + ) + + assert all(handler.prompt_tokens + handler.count_tokens(chunk) + reserve <= limit for chunk in chunks) + assert chunks == (["A\nB"] if overflow == 0 else ["A", "B"][:max_calls]) + assert remaining == (["b.py"] if overflow and max_calls == 1 else []) + + +@pytest.mark.parametrize( + ("max_calls", "expected_chunks", "expected_remaining"), + [ + (2, ["A", "B\nC\nD"], ["e.py"]), + (3, ["A", "B\nC\nD", "E"], []), + ], +) +def test_multi_packing_repairs_non_monotone_prefixes_in_order( + max_calls, expected_chunks, expected_remaining, +): + handler = NonMonotoneTokenHandler() + file_dict = { + f"{patch.lower()}.py": {"patch": patch, "tokens": 1} + for patch in "ABCDE" + } + + chunks, remaining = pr_processing._pack_pr_multi_diffs( + file_dict, handler, max_calls, True, token_budget=5, + ) + + assert chunks == expected_chunks + assert remaining == expected_remaining + assert all(handler.count_tokens(chunk) <= 5 for chunk in chunks) + + +def test_compressed_packing_repairs_non_monotone_prefixes_in_order(): + handler = NonMonotoneTokenHandler(prompt_tokens=11) + file_dict = { + f"{patch.lower()}.py": { + "patch": patch, + "tokens": 1, + "edit_type": EDIT_TYPE.MODIFIED, + } + for patch in "ABCDE" + } + + total, patches, remaining, included = pr_processing.generate_full_patch( + True, + file_dict, + soft_token_budget=5, + remaining_files_list_prev=list(file_dict), + token_handler=handler, + hard_token_budget=5, + ) + + assert total == handler.prompt_tokens + 1 + assert patches == ["\n\nA"] + assert included == ["a.py"] + assert remaining == ["b.py", "c.py", "d.py", "e.py"] + + +def test_extended_diff_counts_trimmed_rendering(): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch and patch == patch.strip() else super().count_tokens(patch) + + handler = StripSensitiveHandler(prompt_tokens=11) + _, total, _ = pr_processing.pr_generate_extended_diff( + [{"files": [_rendered_budget_files()[0]]}], handler, False, + ) + + assert total == handler.prompt_tokens + 100 + + +def test_compressed_packing_rejects_trimmed_overflow(): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "AB" else super().count_tokens(patch) + + handler = StripSensitiveHandler(prompt_tokens=0) + total, patches, remaining, included = pr_processing.generate_full_patch( + True, + {"a.py": {"patch": "AB", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}}, + soft_token_budget=4, + remaining_files_list_prev=["a.py"], + token_handler=handler, + hard_token_budget=4, + ) + + assert total == 0 + assert patches == [] + assert remaining == ["a.py"] + assert included == [] + + +@pytest.mark.parametrize("policy", ["skip", "clip"]) +def test_multi_packing_does_not_assume_stripping_reduces_tokens(monkeypatch, policy): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "AB" else len(patch) + + handler = StripSensitiveHandler(prompt_tokens=0) + settings = get_settings() + original_policy = settings.config.get("large_patch_policy", "skip") + settings.config.large_patch_policy = policy + monkeypatch.setattr(pr_processing, "clip_tokens", lambda patch, *args, **kwargs: patch) + try: + chunks, remaining = pr_processing._pack_pr_multi_diffs( + {"a.py": {"patch": " AB ", "tokens": 4}}, handler, 2, True, 4, + ) + finally: + settings.config.large_patch_policy = original_policy + + assert chunks == [] + assert remaining == ["a.py"] + + +def test_multi_packing_clips_with_exact_single_patch_count(monkeypatch): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "AB" else len(patch) + + observed_counts = [] + + def clip_with_reported_count(patch, max_tokens, *, num_input_tokens, **kwargs): + observed_counts.append(num_input_tokens) + return patch if num_input_tokens <= max_tokens else "A" + + handler = StripSensitiveHandler(prompt_tokens=0) + settings = get_settings() + original_policy = settings.config.get("large_patch_policy", "skip") + settings.config.large_patch_policy = "clip" + monkeypatch.setattr(pr_processing, "clip_tokens", clip_with_reported_count) + try: + chunks, remaining = pr_processing._pack_pr_multi_diffs( + {"a.py": {"patch": " AB ", "tokens": 4}}, handler, 2, True, 4, + ) + finally: + settings.config.large_patch_policy = original_policy + + assert chunks == ["A"] + assert remaining == [] + assert observed_counts == [100] + + +def test_fresh_and_prepared_multi_diffs_fit_the_same_rendered_boundary(monkeypatch): + handler = CharacterTokenHandler(prompt_tokens=11) + files = _rendered_budget_files() + patches, _, _ = pr_processing.pr_generate_extended_diff([{"files": files}], handler, True) + reserve = 5_000 + limit = handler.prompt_tokens + handler.count_tokens("\n".join(patches[:2])) + reserve - 1 + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: limit) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda langs, files: [{"files": files}]) + monkeypatch.setattr(pr_processing, "extend_patch", lambda original, patch, *args, **kwargs: patch) + provider = FakeProvider(files) + prepared = pr_processing.get_pr_diff( + provider, handler, "model", add_line_numbers_to_hunks=True, return_prepared=True, + output_token_reserve=lambda model, default: reserve, + ) + assert prepared.file_dict + + prepared_chunks = pr_processing.get_pr_multi_diffs( + provider, handler, "model", prepared_diff=prepared, return_remaining_files=True, + output_token_reserve=lambda model, default: reserve, + ) + fresh_chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_rendered_budget_files()), handler, "model", return_remaining_files=True, + output_token_reserve=lambda model, default: reserve, + ) + + assert prepared_chunks == fresh_chunks + chunks, remaining = prepared_chunks + assert not remaining + assert len(chunks) > 1 + assert all(handler.prompt_tokens + handler.count_tokens(chunk) + reserve <= limit for chunk in chunks) + assert provider.diff_calls == 1 + + +@pytest.mark.parametrize( + ("max_tokens", "expected_diff", "expected_tokens"), + [ + (9, "base", 7), + (10, "base\n\nx", 10), + ], +) +def test_append_metadata_section_reserves_exact_separator_capacity( + max_tokens, expected_diff, expected_tokens +): + token_handler = CharacterTokenHandler(prompt_tokens=3) + + final_diff, curr_token, _ = pr_processing._append_metadata_section( + "base", 7, "x", max_tokens, token_handler + ) + + assert final_diff == expected_diff + assert curr_token == expected_tokens + + +def test_append_metadata_section_omits_non_additive_clipped_candidate(monkeypatch): + class NonAdditiveTokenHandler(CharacterTokenHandler): + candidate_counts = 0 + + def count_tokens(self, patch): + if patch.startswith("A\n\n"): + self.candidate_counts += 1 + tokens = super().count_tokens(patch) + return tokens + 2 if patch.startswith("A\n\n") else tokens + + clip_calls = 0 + + def clip_with_marker(text, max_tokens, **kwargs): + nonlocal clip_calls + clip_calls += 1 + if len(text) <= max_tokens: + return text + return text[:max(0, max_tokens - 1)] + "…" + + monkeypatch.setattr(pr_processing, "clip_tokens", clip_with_marker) + token_handler = NonAdditiveTokenHandler(prompt_tokens=0) + + final_diff, curr_token, clipped = pr_processing._append_metadata_section( + "A", 1, "BBBB", 6, token_handler + ) + + assert clipped == "" + assert final_diff == "A" + assert curr_token == 1 + assert clip_calls == 1 + assert token_handler.candidate_counts == 1 + + +def test_append_metadata_section_omits_trimmed_overflow(): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "A\n\nB" else super().count_tokens(patch) + + token_handler = StripSensitiveHandler(prompt_tokens=0) + final_diff, curr_token, clipped = pr_processing._append_metadata_section( + " A", 2, "B", 8, token_handler, + ) + + assert clipped == "" + assert final_diff == " A" + assert curr_token == 2 + + +def test_append_metadata_sections_keep_complete_diff_within_budget(): + token_handler = CharacterTokenHandler(prompt_tokens=3) + max_tokens = token_handler.prompt_tokens + len("base\n\naa\n\nbb") + final_diff = "base" + curr_token = token_handler.prompt_tokens + token_handler.count_tokens(final_diff) + + for section in ("aa", "bb"): + final_diff, curr_token, _ = pr_processing._append_metadata_section( + final_diff, curr_token, section, max_tokens, token_handler + ) + + assert final_diff == "base\n\naa\n\nbb" + assert curr_token == token_handler.prompt_tokens + token_handler.count_tokens(final_diff) + assert curr_token == max_tokens + + +@pytest.mark.parametrize( + ("edit_type", "heading"), + [ + (EDIT_TYPE.ADDED, pr_processing.ADDED_FILES_.strip()), + (EDIT_TYPE.MODIFIED, pr_processing.MORE_MODIFIED_FILES_.strip()), + (EDIT_TYPE.RENAMED, pr_processing.MORE_MODIFIED_FILES_.strip()), + (EDIT_TYPE.DELETED, pr_processing.DELETED_FILES_.strip()), + ], +) +def test_get_pr_diff_routes_each_metadata_type_through_bounded_append( + monkeypatch, edit_type, heading +): + token_handler = CharacterTokenHandler(prompt_tokens=0) + file_dict = {"metadata.py": {"edit_type": edit_type}} + appended_sections = [] + original_append_metadata_section = pr_processing._append_metadata_section + + def append_metadata_section(*args, **kwargs): + appended_sections.append(args[2]) + return original_append_metadata_section(*args, **kwargs) + + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1_500) + monkeypatch.setattr(pr_processing, "_append_metadata_section", append_metadata_section) + monkeypatch.setattr( + pr_processing, + "sort_files_by_main_languages", + lambda languages, files: [{"files": files}], + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_extended_diff", + lambda *args, **kwargs: (["full diff"], 1_500, []), + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_compressed_diff", + lambda *args, **kwargs: ([["base"]], [1_498], [], [], file_dict, [[]]), + ) + + diff = pr_processing.get_pr_diff( + FakeProvider([]), + token_handler, + "model", + output_token_reserve=lambda model, default: 1, + ) + + assert heading in diff + assert "metadata.py" in diff + assert [section for section in appended_sections if section] == [f"{heading}\n\nmetadata.py"] + assert token_handler.prompt_tokens + token_handler.count_tokens(diff) <= 500 + + +def _make_budget_files(tokens_per_file=2_800): + return [ + FilePatchInfo( + base_file="old\n", + head_file="new\n", + patch="@@ -1 +1 @@\n-old\n+" + ("token " * tokens_per_file), + filename=f"file_{index}.py", + edit_type=EDIT_TYPE.MODIFIED, + ) + for index in range(2) + ] + + +@pytest.mark.parametrize("resolved", [None, 0, -1, True, "5000"]) +def test_output_token_reserve_rejects_unusable_values(resolved): + budget = token_budget.AttemptTokenBudget( + "model", object(), object(), 10_000, output_token_reserve=lambda model, default: resolved, + ) + + assert budget.resolve_output_reserve(1_500, preserve_minimum=True) == 1_500 + + +@pytest.mark.parametrize("default", [1_000, 1_500]) +@pytest.mark.parametrize("resolved", [1, 100, 999, 1_000, 1_200, 1_499, 1_500, 5_000]) +def test_output_token_reserve_keeps_the_legacy_minimum(default, resolved): + budget = token_budget.AttemptTokenBudget( + "model", object(), object(), 10_000, output_token_reserve=lambda model, fallback: resolved, + ) + + assert budget.resolve_output_reserve(default, preserve_minimum=True) == max(default, resolved) + + +@pytest.mark.parametrize("packing_path", ["single", "multi", "multiple_patchs", "prepared"]) +def test_small_output_reserve_preserves_legacy_packing(monkeypatch, packing_path): + monkeypatch.setattr(get_settings().config, "patch_extra_lines_before", 0) + monkeypatch.setattr(get_settings().config, "patch_extra_lines_after", 0) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 6_500) + + def pack(**kwargs): + provider = FakeProvider(_make_budget_files()) + token_handler = FakeTokenHandler(prompt_tokens=100) + if packing_path == "single": + return pr_processing.get_pr_diff(provider, token_handler, "model", **kwargs) + if packing_path == "multiple_patchs": + return pr_processing.get_pr_diff_multiple_patchs(provider, token_handler, "model", **kwargs) + if packing_path == "prepared": + prepared = pr_processing.get_pr_diff( + provider, token_handler, "model", add_line_numbers_to_hunks=True, + return_prepared=True, **kwargs, + ) + return pr_processing.get_pr_multi_diffs( + provider, token_handler, "model", prepared_diff=prepared, **kwargs + ) + return pr_processing.get_pr_multi_diffs(provider, token_handler, "model", **kwargs) + + assert pack(output_token_reserve=lambda model, default: 100) == pack() + + +@pytest.mark.parametrize("packing_path", ["get_pr_diff", "get_pr_diff_multiple_patchs"]) +def test_compressed_paths_keep_distinct_soft_and_hard_floors(monkeypatch, packing_path): + monkeypatch.setattr(get_settings().config, "patch_extra_lines_before", 0) + monkeypatch.setattr(get_settings().config, "patch_extra_lines_after", 0) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 6_500) + original_compressed_diff = pr_processing.pr_generate_compressed_diff + reserves = [] + + def compressed_diff(*args, **kwargs): + reserves.append((args[2], args[3])) + return original_compressed_diff(*args, **kwargs) + + monkeypatch.setattr(pr_processing, "pr_generate_compressed_diff", compressed_diff) + getattr(pr_processing, packing_path)( + FakeProvider(_make_budget_files()), FakeTokenHandler(prompt_tokens=100), "model", + output_token_reserve=lambda model, default: 1_200, + ) + + assert reserves == [(4_900, 5_200)] + + +def test_output_token_reserve_falls_back_independently_when_one_resolution_fails(): + calls = [] + + def resolve(model, default): + calls.append((model, default)) + if default == pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: + return 5_000 + raise RuntimeError("hard reserve unavailable") + + budget = token_budget.AttemptTokenBudget("model", object(), object(), 10_000, output_token_reserve=resolve) + + assert budget.resolve_output_reserve(1_500, preserve_minimum=True) == 5_000 + assert budget.resolve_output_reserve(1_000, preserve_minimum=True) == 1_000 + assert calls == [("model", 1_500), ("model", 1_000)] + + +def test_get_pr_multi_diffs_reserves_the_active_completion_allowance(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "large_patch_policy": settings.config.get("large_patch_policy", "skip"), + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.large_patch_policy = "skip" + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + + try: + default_chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_make_budget_files()), + FakeTokenHandler(prompt_tokens=100), + "model", + add_line_numbers=False, + ) + token_handler = FakeTokenHandler(prompt_tokens=100) + reserved_chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_make_budget_files()), + token_handler, + "model", + add_line_numbers=False, + output_token_reserve=lambda model, default: 5_000, + ) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + assert len(default_chunks) == 1 + assert len(reserved_chunks) == 2 + assert all(token_handler.prompt_tokens + token_handler.count_tokens(chunk) <= 5_000 + for chunk in reserved_chunks) + + +def test_get_pr_diff_reserves_output_in_compressed_diff_and_metadata(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "verbosity_level": settings.config.verbosity_level, + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.verbosity_level = 0 + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + token_handler = FakeTokenHandler(prompt_tokens=100) + + try: + diff, remaining_files = pr_processing.get_pr_diff( + FakeProvider(_make_budget_files()), + token_handler, + "model", + return_remaining_files=True, + output_token_reserve=lambda model, default: 5_000, + ) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + assert "file_0.py" in diff + assert "file_1.py" in diff + assert remaining_files == ["file_1.py"] + assert token_handler.prompt_tokens + token_handler.count_tokens(diff) <= 5_000 + + +def test_get_pr_diff_uses_the_dynamic_hard_reserve_for_metadata(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "verbosity_level": settings.config.verbosity_level, + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.verbosity_level = 0 + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + reserve_calls = [] + + def resolve(model, default): + reserve_calls.append((model, default)) + if default == pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: + return 5_000 + return 7_500 + + token_handler = FakeTokenHandler(prompt_tokens=100) + + try: + diff, remaining_files = pr_processing.get_pr_diff( + FakeProvider(_make_budget_files()), + token_handler, + "model", + return_remaining_files=True, + output_token_reserve=resolve, + ) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + assert "file_0.py" in diff + assert "file_1.py" not in diff + assert remaining_files == ["file_1.py"] + assert token_handler.prompt_tokens + token_handler.count_tokens(diff) > 10_000 - 7_500 + assert reserve_calls == [("model", 1_500), ("model", 1_000)] + + +def test_get_pr_diff_multiple_patchs_resolves_the_output_allowance(monkeypatch): + settings = get_settings() + original = { + "max_ai_calls": settings.pr_description.max_ai_calls, + "verbosity_level": settings.config.verbosity_level, + } + settings.pr_description.max_ai_calls = 4 + settings.config.verbosity_level = 0 + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + + try: + default_result = pr_processing.get_pr_diff_multiple_patchs( + FakeProvider(_make_budget_files()), FakeTokenHandler(prompt_tokens=100), "model" + ) + reserved_result = pr_processing.get_pr_diff_multiple_patchs( + FakeProvider(_make_budget_files()), + FakeTokenHandler(prompt_tokens=100), + "model", + output_token_reserve=lambda model, default: 5_000, + ) + finally: + settings.pr_description.max_ai_calls = original["max_ai_calls"] + settings.config.verbosity_level = original["verbosity_level"] + + assert len(default_result[0]) == 1 + assert len(reserved_result[0]) == 2 + + +def test_prepared_multi_diffs_apply_the_current_attempt_reserve(monkeypatch): + token_handler = FakeTokenHandler(prompt_tokens=100) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + file_dict = { + f"file_{index}.py": { + "patch": "token " * 2_800, + "tokens": 2_800, + "edit_type": EDIT_TYPE.MODIFIED, + } + for index in range(2) + } + prepared = pr_processing.PreparedPRDiff( + diff="prepared", + remaining_files_list=[], + file_dict=file_dict, + files_by_name={}, + model="model", + add_line_numbers_to_hunks=True, + token_handler=token_handler, + ) + + chunks = pr_processing.get_pr_multi_diffs( + FakeProvider([]), + token_handler, + "model", + prepared_diff=prepared, + output_token_reserve=lambda model, default: 5_000, + ) + + assert len(chunks) == 2 + + +def test_direct_compressed_helpers_accept_explicit_numeric_budgets(monkeypatch): + context_window = 10_000 + prompt_tokens = 100 + soft_token_budget = context_window - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - prompt_tokens + hard_token_budget = context_window - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - prompt_tokens + monkeypatch.setattr( + token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: context_window + ) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + file_dict = { + "file.py": {"patch": "+ change", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + token_handler = FakeTokenHandler(prompt_tokens=prompt_tokens) + + total, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( + False, + file_dict, + soft_token_budget, + ["file.py"], + token_handler, + hard_token_budget=hard_token_budget, + ) + + expected_compressed = pr_processing.pr_generate_compressed_diff( + [{"files": _make_budget_files(tokens_per_file=10)[:1]}], + FakeTokenHandler(prompt_tokens), + soft_token_budget, + hard_token_budget, + False, + True, + ) + actual_compressed = pr_processing.get_pr_diff_multiple_patchs( + FakeProvider(_make_budget_files(tokens_per_file=10)[:1]), + FakeTokenHandler(prompt_tokens), + "model", + ) + + assert total == token_handler.prompt_tokens + token_handler.count_tokens("\n".join(patches)) + assert remaining_files == [] + assert files_in_patch == ["file.py"] + assert actual_compressed == expected_compressed + + +def test_generate_full_patch_preserves_soft_boundary_equality(): + token_handler = FakeTokenHandler(prompt_tokens=100) + file_dict = { + "file.py": {"patch": "+ exact boundary", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + rendered_tokens = token_handler.count_tokens("\n\n## File: 'file.py'\n\n+ exact boundary\n") + _, _, remaining_files, files_in_patch = pr_processing.generate_full_patch( + False, + file_dict, + rendered_tokens, + ["file.py"], + token_handler, + hard_token_budget=rendered_tokens, + ) + + assert files_in_patch == ["file.py"] + assert remaining_files == [] + + +def test_generate_full_patch_preserves_hard_boundary_equality(): + token_handler = FakeTokenHandler(prompt_tokens=100) + file_dict = { + "file.py": {"patch": "+ exact boundary", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + + _, _, remaining_files, files_in_patch = pr_processing.generate_full_patch( + False, + file_dict, + 4_000, + ["file.py"], + token_handler, + hard_token_budget=0, + ) + + assert files_in_patch == ["file.py"] + assert remaining_files == [] + + +def test_pack_pr_multi_diffs_preserves_soft_boundary_equality(): + token_handler = FakeTokenHandler(prompt_tokens=100) + file_dict = { + "file.py": {"patch": "exact boundary", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + chunks = pr_processing._pack_pr_multi_diffs( + file_dict, + token_handler, + 1, + False, + file_dict["file.py"]["tokens"], + ) + + assert chunks == ["exact boundary"] + + +@pytest.mark.parametrize(("extra_capacity", "uses_full_diff"), [(0, False), (1, True)]) +@pytest.mark.parametrize("reserve", [100, 1_000, 1_200, 1_500, 5_000]) +def test_get_pr_diff_preserves_strict_full_diff_boundary(monkeypatch, extra_capacity, uses_full_diff, reserve): + settings = get_settings() + original_before = settings.config.patch_extra_lines_before + original_after = settings.config.patch_extra_lines_after + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + original_generate_extended_diff = pr_processing.pr_generate_extended_diff + probe_handler = FakeTokenHandler(prompt_tokens=100) + probe_files = _make_budget_files(tokens_per_file=1)[:1] + probe_patches, probe_total, _ = original_generate_extended_diff( + [{"files": probe_files}], probe_handler, False, patch_extra_lines_before=0, patch_extra_lines_after=0 + ) + full_diff = "\n".join(probe_patches) + context_window = probe_total + max(reserve, 1_500) + extra_capacity + + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr( + token_budget, + "get_max_tokens", + lambda model, ignore_max_model_tokens=False: context_window, + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_compressed_diff", + lambda *args, **kwargs: ([["compressed"]], [101], [], [], {}, [[]]), + ) + + try: + diff = pr_processing.get_pr_diff( + FakeProvider(_make_budget_files(tokens_per_file=1)[:1]), + FakeTokenHandler(prompt_tokens=100), + "model", + output_token_reserve=lambda model, default: reserve, + ) + finally: + settings.config.patch_extra_lines_before = original_before + settings.config.patch_extra_lines_after = original_after + + assert diff == (full_diff if uses_full_diff else "compressed") + + +@pytest.mark.parametrize(("extra_capacity", "uses_full_diff"), [(0, False), (1, True)]) +@pytest.mark.parametrize("reserve", [100, 1_000, 1_200, 1_500, 5_000]) +def test_get_pr_multi_diffs_preserves_strict_full_diff_boundary( + monkeypatch, extra_capacity, uses_full_diff, reserve, +): + settings = get_settings() + original_before = settings.config.patch_extra_lines_before + original_after = settings.config.patch_extra_lines_after + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + packed_budgets = [] + original_generate_extended_diff = pr_processing.pr_generate_extended_diff + probe_handler = FakeTokenHandler(prompt_tokens=100) + probe_files = _make_budget_files(tokens_per_file=1)[:1] + probe_patches, probe_total, _ = original_generate_extended_diff( + [{"files": probe_files}], probe_handler, True, patch_extra_lines_before=0, patch_extra_lines_after=0 + ) + full_diff = "\n".join(probe_patches) + context_window = probe_total + max(reserve, 1_500) + extra_capacity + + def pack_diffs(*args): + packed_budgets.append(args[-1]) + return ["compressed"] + + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr( + token_budget, + "get_max_tokens", + lambda model, ignore_max_model_tokens=False: context_window, + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_compressed_diff", + lambda *args, **kwargs: ([], [], [], [], {}, []), + ) + monkeypatch.setattr(pr_processing, "_pack_pr_multi_diffs", pack_diffs) + + try: + chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_make_budget_files(tokens_per_file=1)[:1]), + FakeTokenHandler(prompt_tokens=100), + "model", + output_token_reserve=lambda model, default: reserve, + ) + finally: + settings.config.patch_extra_lines_before = original_before + settings.config.patch_extra_lines_after = original_after + + assert chunks == ([full_diff] if uses_full_diff else ["compressed"]) + assert packed_budgets == ([] if uses_full_diff else [probe_total - probe_handler.prompt_tokens]) + + def test_prepared_pr_diff_reuses_compressed_files_without_changing_chunks(monkeypatch): settings = get_settings() original = { @@ -61,7 +1011,7 @@ def test_prepared_pr_diff_reuses_compressed_files_without_changing_chunks(monkey token_handler = FakeTokenHandler(prompt_tokens=100) monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1_700) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1_700) try: prepared = pr_processing.get_pr_diff( @@ -78,15 +1028,25 @@ def test_prepared_pr_diff_reuses_compressed_files_without_changing_chunks(monkey expected_order = [f"file_{index}.py" for index in range(3, -1, -1)] assert list(prepared.file_dict) == expected_order calls_after_prepare = token_handler.count_calls - prepared_chunks = pr_processing.get_pr_multi_diffs( - provider, - token_handler, - "tiny-model", - max_calls=3, - add_line_numbers=True, - return_remaining_files=True, - prepared_diff=prepared, - ) + + def unexpected_preparation(*args, **kwargs): + pytest.fail("Prepared patches must not be transformed again") + + with monkeypatch.context() as packing_patch: + packing_patch.setattr(pr_processing, "extend_patch", unexpected_preparation) + packing_patch.setattr(pr_processing, "handle_patch_deletions", unexpected_preparation) + packing_patch.setattr( + pr_processing, "decouple_and_convert_to_hunks_with_lines_numbers", unexpected_preparation, + ) + prepared_chunks = pr_processing.get_pr_multi_diffs( + provider, + token_handler, + "tiny-model", + max_calls=3, + add_line_numbers=True, + return_remaining_files=True, + prepared_diff=prepared, + ) fresh_provider = FakeProvider([ FilePatchInfo("old\n", "new\n", hunks[index], f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) @@ -107,13 +1067,112 @@ def test_prepared_pr_diff_reuses_compressed_files_without_changing_chunks(monkey assert [combined_chunks.index(filename) for filename in expected_order] == sorted( combined_chunks.index(filename) for filename in expected_order ) - assert token_handler.count_calls == calls_after_prepare + # Bound exact candidate checks linearly while keeping preparation cached. + assert 0 < token_handler.count_calls - calls_after_prepare <= 6 * len(prepared.file_dict) assert (provider.diff_calls, provider.language_calls) == (1, 1) finally: for key, value in original.items(): setattr(settings.config, key, value) +def test_exact_packing_checks_stay_within_linear_bound_for_many_files(): + file_count = 400 + file_dict = { + f"file_{index}.py": { + "patch": f"patch {index}", + "tokens": 2, + "edit_type": EDIT_TYPE.MODIFIED, + } + for index in range(file_count) + } + + chunk_handler = FakeTokenHandler(prompt_tokens=100) + chunks = pr_processing._pack_pr_multi_diffs( + file_dict, + chunk_handler, + max_calls=5, + return_remaining_files=False, + token_budget=180, + ) + + assert len(chunks) == 5 + assert all(chunk_handler.count_tokens(chunk) <= 180 for chunk in chunks) + assert chunk_handler.count_calls < 3 * file_count + + compressed_handler = FakeTokenHandler(prompt_tokens=100) + _, patches, remaining, included = pr_processing.generate_full_patch( + False, + file_dict, + soft_token_budget=10_000, + remaining_files_list_prev=list(file_dict), + token_handler=compressed_handler, + hard_token_budget=10_000, + ) + + assert len(patches) == file_count + assert remaining == [] + assert len(included) == file_count + assert compressed_handler.count_calls < 3 * file_count + + +def test_overflow_repair_bounds_encoded_volume_for_many_files(): + class RepairProbeTokenHandler(FakeTokenHandler): + def __init__(self, prompt_tokens=100): + super().__init__(prompt_tokens) + self.encoded_characters = 0 + + def count_tokens(self, patch): + self.count_calls += 1 + self.encoded_characters += len(patch) + non_additive_drift = 200 if patch.count("patch-") > 1 else 0 + return len(patch) + non_additive_drift + + file_count = 400 + patch_length = 40 + file_dict = { + f"file_{index}.py": { + "patch": f"patch-{index:03d}".ljust(patch_length, "x"), + "tokens": patch_length, + "edit_type": EDIT_TYPE.MODIFIED, + } + for index in range(file_count) + } + joined_length = file_count * patch_length + file_count - 1 + + chunk_handler = RepairProbeTokenHandler() + chunks = pr_processing._pack_pr_multi_diffs( + file_dict, + chunk_handler, + max_calls=2, + return_remaining_files=False, + token_budget=joined_length, + ) + + assert len(chunks) == 2 + assert all(chunk_handler.count_tokens(chunk) <= joined_length for chunk in chunks) + assert chunk_handler.encoded_characters < 12 * joined_length + + compressed_handler = RepairProbeTokenHandler() + total, patches, remaining, included = pr_processing.generate_full_patch( + True, + file_dict, + soft_token_budget=joined_length + 2 * file_count, + remaining_files_list_prev=list(file_dict), + token_handler=compressed_handler, + hard_token_budget=joined_length + 2 * file_count, + ) + rendered = "\n".join(patches) + + assert total == compressed_handler.prompt_tokens + compressed_handler.count_tokens(rendered) + assert compressed_handler.count_tokens(rendered) <= joined_length + 2 * file_count + assert remaining + assert included + # Exact repair checks both the raw and Jinja-trimmed serializations without recounting per admission. + assert compressed_handler.encoded_characters < 24 * len( + "\n".join("\n\n" + data["patch"] for data in file_dict.values()) + ) + + @pytest.mark.parametrize( ("prepared_model", "requested_model", "prepared_line_numbers", "requested_line_numbers"), [ @@ -135,7 +1194,7 @@ def test_prepared_pr_diff_is_not_reused_across_model_or_patch_format( provider = FakeProvider(files) token_handler = FakeTokenHandler(prompt_tokens=100) monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1_700) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1_700) try: prepared = pr_processing.get_pr_diff( @@ -171,11 +1230,13 @@ def test_prepared_pr_diff_is_not_reused_across_model_or_patch_format( lambda provider, token_handler: pr_processing.get_pr_multi_diffs(provider, token_handler, "model"), ], ) -def test_shared_diff_paths_propagate_project_rate_limit(call_diff): +def test_shared_diff_paths_propagate_project_rate_limit(monkeypatch, call_diff): class RateLimitedProvider(FakeProvider): def get_diff_files(self): raise RateLimitExceeded("rate limit exceeded") + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + with pytest.raises(RateLimitExceeded, match="rate limit exceeded"): call_diff(RateLimitedProvider([]), FakeTokenHandler()) @@ -213,9 +1274,18 @@ def test_generate_full_patch_keeps_remaining_files_when_patch_exceeds_soft_budge total_tokens, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=max_tokens_model, + soft_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD + - token_handler.prompt_tokens + ), remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD + - token_handler.prompt_tokens + ), ) assert total_tokens > token_handler.prompt_tokens @@ -242,9 +1312,10 @@ def count_tokens(self, patch): total_tokens, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=3_000, + soft_token_budget=3_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=3_000 - 1_000 - token_handler.prompt_tokens, ) assert total_tokens > 3_000 - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD @@ -264,9 +1335,10 @@ def test_generate_full_patch_records_too_large_patch_files(): total_tokens, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=4_000, + soft_token_budget=4_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=4_000 - 1_000 - token_handler.prompt_tokens, ) assert total_tokens > token_handler.prompt_tokens @@ -314,7 +1386,10 @@ def test_get_all_deployments_rejects_short_fallback_deployment_list(): settings.set("openai.fallback_deployments", original_fallback_deployments) -def test_get_pr_multi_diffs_clips_large_patch_when_policy_is_clip(monkeypatch): +@pytest.mark.parametrize(("context_limit", "reserve"), [(1_700, None), (5_200, 5_000)]) +def test_get_pr_multi_diffs_clips_large_patch_with_legacy_and_dynamic_reserve( + monkeypatch, context_limit, reserve +): settings = get_settings() original = { "patch_extra_lines_before": settings.config.patch_extra_lines_before, @@ -336,17 +1411,35 @@ def test_get_pr_multi_diffs_clips_large_patch_when_policy_is_clip(monkeypatch): ) provider = FakeProvider([file_info]) token_handler = FakeTokenHandler(prompt_tokens=100) + clip_budgets = [] monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1700) - monkeypatch.setattr(pr_processing, "clip_tokens", lambda patch, *args, **kwargs: "clipped patch") + monkeypatch.setattr( + token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: context_limit + ) + + def clip_patch(patch, token_budget, **kwargs): + clip_budgets.append(token_budget) + return "clipped patch" + + monkeypatch.setattr(pr_processing, "clip_tokens", clip_patch) try: + reserve_kwargs = ( + {"output_token_reserve": lambda model, default: reserve} if reserve is not None else {} + ) diffs = pr_processing.get_pr_multi_diffs( - provider, token_handler, "tiny-model", max_calls=2, add_line_numbers=False + provider, + token_handler, + "tiny-model", + max_calls=2, + add_line_numbers=False, + **reserve_kwargs, ) assert diffs == ["clipped patch"] + expected_reserve = reserve if reserve is not None else pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD + assert clip_budgets == [context_limit - expected_reserve - token_handler.prompt_tokens] finally: settings.config.patch_extra_lines_before = original["patch_extra_lines_before"] settings.config.patch_extra_lines_after = original["patch_extra_lines_after"] @@ -381,7 +1474,7 @@ def _file(filename, patch): token_handler = FakeTokenHandler(prompt_tokens=100) monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1700) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1700) try: diffs, remaining_files = pr_processing.get_pr_multi_diffs( @@ -412,7 +1505,7 @@ def test_get_pr_multi_diffs_reports_no_remaining_files_when_the_whole_diff_fits( token_handler = FakeTokenHandler(prompt_tokens=100) monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 100000) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 100000) try: diffs, remaining_files = pr_processing.get_pr_multi_diffs( diff --git a/tests/unittest/test_review_large_diff_chunking.py b/tests/unittest/test_review_large_diff_chunking.py index 3099566e2c..7d21ca087c 100644 --- a/tests/unittest/test_review_large_diff_chunking.py +++ b/tests/unittest/test_review_large_diff_chunking.py @@ -44,6 +44,7 @@ def _make_reviewer(): reviewer = PRReviewer.__new__(PRReviewer) reviewer.git_provider = MagicMock() + reviewer.ai_handler = MagicMock() reviewer.token_handler = MagicMock() reviewer.pr_url = "https://example/pr/1" reviewer.incremental = SimpleNamespace(is_incremental=False) @@ -112,6 +113,7 @@ async def test_a_truncated_diff_is_reviewed_chunk_by_chunk_and_merged(chunking_e max_calls=3, add_line_numbers=True, return_remaining_files=True, + output_token_reserve=reviewer.ai_handler.get_output_token_reserve, ) assert [call.args[1] for call in reviewer._get_prediction.await_args_list] == ["chunk-a", "chunk-b"] @@ -144,6 +146,8 @@ async def test_chunked_review_reuses_the_prepared_diff_for_the_same_model_attemp assert get_pr_diff.call_args.kwargs["return_prepared"] is True assert get_pr_multi_diffs.call_args.kwargs["prepared_diff"] is prepared + assert get_pr_diff.call_args.kwargs["output_token_reserve"] is reviewer.ai_handler.get_output_token_reserve + assert get_pr_multi_diffs.call_args.kwargs["output_token_reserve"] is reviewer.ai_handler.get_output_token_reserve assert reviewer.review_chunk_count == 2 assert reviewer.remaining_files_list == ["still_left_out.py"] diff --git a/tests/unittest/test_token_handler.py b/tests/unittest/test_token_handler.py index d85fa1a6e2..431bb943a2 100644 --- a/tests/unittest/test_token_handler.py +++ b/tests/unittest/test_token_handler.py @@ -6,6 +6,17 @@ from pr_agent.algo import token_handler +def _settings(model="primary-model", estimate_factor=0, openai_key=None, anthropic_key=None): + return SimpleNamespace( + config=SimpleNamespace(model=model), + get=lambda key, default=None: { + "openai.key": openai_key, + "anthropic.key": anthropic_key, + "config.model_token_count_estimate_factor": estimate_factor, + }.get(key, default), + ) + + def test_oversized_claude_patch_falls_back_to_local_estimate(monkeypatch): settings = SimpleNamespace( config=SimpleNamespace(model="claude-3-7-sonnet-20250219"), @@ -30,3 +41,94 @@ def test_oversized_claude_patch_falls_back_to_local_estimate(monkeypatch): assert handler.count_tokens("abcd", force_accurate=True) == 13 client.messages.count_tokens.assert_not_called() + + +def test_no_pr_handler_initializes_zero_prompt_tokens(monkeypatch): + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: _settings()) + monkeypatch.setattr( + token_handler.TokenEncoder, + "get_token_encoder", + lambda _model=None: MagicMock(), + ) + + handler = token_handler.TokenHandler(model="fallback-model") + + assert handler.model == "fallback-model" + assert handler.prompt_tokens == 0 + + +def test_for_model_rebinds_rendered_prompt_without_mutating_source(monkeypatch): + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: _settings()) + encoders = {} + + class CharacterEncoder: + def __init__(self, model): + self.model = model + + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + def get_encoder(model=None): + encoders.setdefault(model, CharacterEncoder(model)) + return encoders[model] + + monkeypatch.setattr(token_handler.TokenEncoder, "get_token_encoder", get_encoder) + variables = {"title": "PR"} + handler = token_handler.TokenHandler(object(), variables, "system {{ title }}", "user") + + fallback_handler = handler.for_model("fallback-model") + + assert handler.for_model("primary-model") is handler + assert handler.model == "primary-model" + assert fallback_handler is not handler + assert fallback_handler.model == "fallback-model" + assert fallback_handler.vars is variables + assert fallback_handler.encoder.model == "fallback-model" + assert fallback_handler.prompt_tokens == len("system PR") + len("user") + + +def test_for_model_does_not_replace_configured_primary_encoder_cache(monkeypatch): + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: _settings()) + monkeypatch.setattr(token_handler.TokenEncoder, "_encoder_instance", None) + monkeypatch.setattr(token_handler.TokenEncoder, "_model", None) + created_models = [] + + def create_encoder(model): + created_models.append(model) + encoder = MagicMock() + encoder.encode.return_value = [] + return encoder + + monkeypatch.setattr( + token_handler.TokenEncoder, + "_create_encoder", + staticmethod(create_encoder), + ) + handler = token_handler.TokenHandler(object(), {}, "system", "user") + primary_encoder = token_handler.TokenEncoder._encoder_instance + + fallback_handler = handler.for_model("fallback-model") + + assert fallback_handler.encoder is not primary_encoder + assert token_handler.TokenEncoder._encoder_instance is primary_encoder + assert token_handler.TokenEncoder._model == "primary-model" + assert created_models == ["primary-model", "fallback-model"] + + +def test_force_accurate_count_uses_bound_attempt_model(monkeypatch): + settings = _settings( + model="claude-3-7-sonnet-20250219", + estimate_factor=0.3, + openai_key="test-key", + anthropic_key="test-key", + ) + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: settings) + handler = token_handler.TokenHandler.__new__(token_handler.TokenHandler) + handler.model = "gpt-4o" + handler.encoder = MagicMock() + handler.encoder.encode.return_value = [0] * 10 + handler._calc_claude_tokens = MagicMock(return_value=99) + + assert handler.count_tokens("patch", force_accurate=True) == 10 + handler._calc_claude_tokens.assert_not_called() diff --git a/tests/unittest/test_tool_output_reserve_forwarding.py b/tests/unittest/test_tool_output_reserve_forwarding.py new file mode 100644 index 0000000000..2bf379aa86 --- /dev/null +++ b/tests/unittest/test_tool_output_reserve_forwarding.py @@ -0,0 +1,65 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import pr_agent.tools.pr_add_docs as add_docs_module +import pr_agent.tools.pr_generate_labels as generate_labels_module +import pr_agent.tools.pr_questions as questions_module +import pr_agent.tools.pr_update_changelog as update_changelog_module + + +@pytest.mark.parametrize( + "tool_class, tool_module, attributes, diff_options", + [ + ( + add_docs_module.PRAddDocs, + add_docs_module, + {}, + {"add_line_numbers_to_hunks": True, "disable_extra_lines": False}, + ), + (generate_labels_module.PRGenerateLabels, generate_labels_module, {"pr_id": "repo#1"}, {}), + (questions_module.PRQuestions, questions_module, {}, {}), + (update_changelog_module.PRUpdateChangelog, update_changelog_module, {}, {}), + ], + ids=["add-docs", "generate-labels", "questions", "update-changelog"], +) +@pytest.mark.asyncio +async def test_prepare_prediction_forwards_attempt_output_reserve( + monkeypatch, + tool_class, + tool_module, + attributes, + diff_options, +): + def output_token_reserve(model, default): + assert model == "fallback-model" + assert default == 1_500 + return 5_000 + + tool = tool_class.__new__(tool_class) + tool.git_provider = object() + tool.token_handler = object() + tool.ai_handler = SimpleNamespace(get_output_token_reserve=output_token_reserve) + prediction = "labels: []\n" if tool_class is generate_labels_module.PRGenerateLabels else "prediction" + tool._get_prediction = AsyncMock(return_value=prediction) + for name, value in attributes.items(): + setattr(tool, name, value) + + get_pr_diff = MagicMock(return_value="diff") + monkeypatch.setattr(tool_module, "get_pr_diff", get_pr_diff) + + await tool._prepare_prediction("fallback-model") + + get_pr_diff.assert_called_once_with( + tool.git_provider, + tool.token_handler, + "fallback-model", + output_token_reserve=output_token_reserve, + **diff_options, + ) + forwarded_reserve = get_pr_diff.call_args.kwargs["output_token_reserve"] + assert forwarded_reserve("fallback-model", 1_500) == 5_000 + tool._get_prediction.assert_awaited_once_with("fallback-model") + assert tool.patches_diff == "diff" + assert tool.prediction == prediction From 68e8fd5542b0ee618a88bc447eebfa74fc04ecd4 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:41:43 +0545 Subject: [PATCH 121/238] fix(bitbucket): enable inline key issue deduplication (#3387) --- docs/docs/tools/review.md | 2 +- .../usage-guide/configuration_reference.md | 2 +- pr_agent/algo/inline_comment_dedup.py | 54 +++++++++++----- pr_agent/git_providers/bitbucket_provider.py | 21 ++++++ pr_agent/settings/configuration.toml | 4 +- pr_agent/tools/pr_reviewer.py | 16 +++-- .../test_bitbucket_inline_comment_dedup.py | 64 +++++++++++++++++++ tests/unittest/test_inline_comment_dedup.py | 33 +++++++++- 8 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 tests/unittest/test_bitbucket_inline_comment_dedup.py diff --git a/docs/docs/tools/review.md b/docs/docs/tools/review.md index 7ff51f00f4..8ebd2cb04b 100644 --- a/docs/docs/tools/review.md +++ b/docs/docs/tools/review.md @@ -116,7 +116,7 @@ for the authoritative default values.
    - +
    inline_key_issuesAzure DevOps only. If set to true, each key issue is published as an inline thread. A finding leaves the review summary when a matching thread exists or Azure accepts the new thread. Findings that cannot be anchored or published stay in the summary.If set to true, each key issue is published as an inline comment where the provider supports verified inline-comment publication. A finding leaves the review summary when a matching comment exists or the provider accepts the new comment. Findings that cannot be anchored or published stay in the summary.
    diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index a0c91307df..ca31a215e1 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -87,7 +87,7 @@ to-do list. | `output_run_cost` | false | if true, collect estimated LiteLLM API cost and include it inside the enabled run details section | | `large_patch_policy` | "clip" | "clip", "skip" | | `duplicate_prompt_examples` | false | | -| `persistent_inline_comments` | false | Persistent inline comments (issue #2037): when true, the GitHub, GitLab, and Azure DevOps providers fingerprint each inline comment, embed the fingerprint as an HTML marker, and skip re-posting suggestions already present on the PR/MR across runs. | +| `persistent_inline_comments` | false | Persistent inline comments (issue #2037): when true, providers with inline-comment deduplication support fingerprint each inline comment, embed the fingerprint as an HTML marker, and skip re-posting suggestions already present on the PR/MR across runs. | **seed** | Key | Default | Description | diff --git a/pr_agent/algo/inline_comment_dedup.py b/pr_agent/algo/inline_comment_dedup.py index 0d444fcbb4..2c590ecb6a 100644 --- a/pr_agent/algo/inline_comment_dedup.py +++ b/pr_agent/algo/inline_comment_dedup.py @@ -36,9 +36,19 @@ import re from typing import Iterator, Optional -BODY_MARKER_RE = re.compile(r"") -CODE_MARKER_RE = re.compile(r"") -KEY_ISSUE_LOCATION_MARKER_RE = re.compile(r"") +_MARKER_URL = r"https://github.com/The-PR-Agent/pr-agent" +BODY_MARKER_RE = re.compile( + rf"|\[pr-agent-dedup: " + rf"([a-f0-9]{{12}})\]: {_MARKER_URL}" +) +CODE_MARKER_RE = re.compile( + rf"|\[pr-agent-dedup-code: " + rf"([a-f0-9]{{12}})\]: {_MARKER_URL}" +) +KEY_ISSUE_LOCATION_MARKER_RE = re.compile( + rf"|" + rf"\[pr-agent-key-issue-location: ([a-f0-9]{{12}})\]: {_MARKER_URL}" +) _MARKER_RES = (BODY_MARKER_RE, CODE_MARKER_RE, KEY_ISSUE_LOCATION_MARKER_RE) _LEAD_RE = re.compile(r"^\*\*Suggestion:\*\*\s*", re.IGNORECASE) @@ -63,7 +73,7 @@ def marker_fingerprints(body: str) -> set: found = set() for marker_re in _MARKER_RES: for match in marker_re.finditer(body or ""): - found.add(match.group(1)) + found.update(group for group in match.groups() if group) return found @@ -72,6 +82,7 @@ def _strip_markers(body: str) -> str: same as its original (markers are appended after marking).""" body = BODY_MARKER_RE.sub("", body or "") body = CODE_MARKER_RE.sub("", body) + body = KEY_ISSUE_LOCATION_MARKER_RE.sub("", body) return body @@ -124,10 +135,17 @@ def extract_suggestion_code(body: str) -> Optional[str]: return match.group(1).strip("\n") -def build_markers(body_fp: str, code_fp: Optional[str]) -> str: - markers = [f""] +def _render_marker(prefix: str, fingerprint: str, git_provider=None) -> str: + supports_html = getattr(git_provider, "supports_html_comment_markers", lambda: True) + if supports_html() is False: + return f"[{prefix}: {fingerprint}]: https://github.com/The-PR-Agent/pr-agent" + return f"" + + +def build_markers(body_fp: str, code_fp: Optional[str], git_provider=None) -> str: + markers = [_render_marker("pr-agent-dedup", body_fp, git_provider)] if code_fp is not None: - markers.append(f"") + markers.append(_render_marker("pr-agent-dedup-code", code_fp, git_provider)) return "\n".join(markers) @@ -139,17 +157,17 @@ def _append_markers(body: str, markers: str, max_chars: Optional[int]) -> str: def body_with_markers(body: str, body_fp: str, code_fp: "Optional[str]", - max_chars: "Optional[int]" = None) -> str: + max_chars: "Optional[int]" = None, git_provider=None) -> str: """Append the dedup marker(s) to a comment body. If max_chars is given and body + markers would exceed it, the body is clipped (never the markers) so the fingerprint marker always survives for the next run's scan.""" - return _append_markers(body, build_markers(body_fp, code_fp), max_chars) + return _append_markers(body, build_markers(body_fp, code_fp, git_provider), max_chars) def key_issue_body_with_markers(body: str, body_fp: str, location_fp: str, - max_chars: Optional[int] = None) -> str: - markers = (f"{build_markers(body_fp, None)}\n" - f"") + max_chars: Optional[int] = None, git_provider=None) -> str: + markers = (f"{build_markers(body_fp, None, git_provider)}\n" + f"{_render_marker('pr-agent-key-issue-location', location_fp, git_provider)}") return _append_markers(body, markers, max_chars) @@ -164,10 +182,16 @@ def inline_comment_line(comment: dict): def iter_existing_inline_comment_bodies(git_provider) -> Iterator[str]: """Yield the body of every existing comment on the current PR/MR. - Dispatch is by provider class name so this module needs no provider - import. Unsupported providers raise NotImplementedError, which the store - treats as "cannot dedup here" and degrades to within-run dedup only. + Providers with a persistent comment-body capability are handled through + that interface. The remaining legacy providers use their provider-specific + APIs; unsupported providers raise NotImplementedError so the store can + degrade to within-run dedup only. """ + persistent_bodies = getattr(git_provider, "get_persistent_comment_bodies", None) + if callable(persistent_bodies): + yield from persistent_bodies() + return + provider_name = type(git_provider).__name__ if provider_name == "GithubProvider": for comment in git_provider.pr.get_comments(): diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index 4c0f5055a5..174211d0cd 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -64,6 +64,7 @@ def get_token(token_name, auth_type_name): self.headers = s.headers self.bitbucket_client = Cloud(session=s) self.max_comment_length = 31000 + self.max_comment_chars = self.max_comment_length self.workspace_slug = None self.repo_slug = None self.repo = None @@ -71,6 +72,7 @@ def get_token(token_name, auth_type_name): self.pr = None self.pr_url = pr_url self.temp_comments = [] + self._published_inline_comment_bodies = [] self.incremental = incremental self.diff_files = None self.git_files = None @@ -472,6 +474,12 @@ def _post_inline_comment(self, body: str, relevant_file: str, from_line: int, to get_logger().error( f"Failed to publish inline comment to '{relevant_file}' at {location}, error: {e}") return False + recent_bodies = getattr(self, "_published_inline_comment_bodies", None) + if recent_bodies is None: + recent_bodies = [] + self._published_inline_comment_bodies = recent_bodies + if body not in recent_bodies: + recent_bodies.append(body) return True def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str: @@ -576,6 +584,19 @@ def get_issue_comments(self): return comments + def get_persistent_comment_bodies(self) -> list[str]: + """Return existing Bitbucket Cloud comment bodies for inline deduplication.""" + bodies = list(getattr(self, "_published_inline_comment_bodies", [])) + for comment in self.get_issue_comments(): + body = getattr(comment, "body", "") + if body and body not in bodies: + bodies.append(body) + return bodies + + def get_recent_inline_comment_bodies(self) -> list[str]: + """Return inline comment bodies published during this provider run.""" + return list(getattr(self, "_published_inline_comment_bodies", [])) + def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: return True diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 72a94ff191..2193954bfd 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -62,8 +62,8 @@ output_run_details=false # if true, append an agent run details section (model, output_run_cost=false # if true, collect estimated LiteLLM API cost and include it inside the enabled run details section large_patch_policy = "clip" # "clip", "skip" duplicate_prompt_examples = false -# Persistent inline comments (issue #2037): when true, the GitHub, GitLab, and Azure DevOps providers -# fingerprint each inline comment, embed the fingerprint as an HTML marker, and skip +# Enable persistent inline comments (issue #2037) to fingerprint each inline comment, +# embed a provider-compatible marker, and skip # re-posting suggestions already present on the PR/MR across runs. persistent_inline_comments = false # seed diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 9bc94528d3..039f4333b8 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -1143,7 +1143,7 @@ def _published_inline_key_issue_fingerprints(self, store: InlineCommentStore, store.add_body(body) except Exception as e: get_logger().warning( - f"Inline key-issue publishing cannot verify new Azure DevOps threads, error: {e}; " + f"Inline key-issue publishing cannot verify newly published comments, error: {e}; " "keeping findings in the review summary") return set() return {fingerprint for fingerprint in fingerprints if store.seen(fingerprint)} @@ -1167,7 +1167,7 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: store = get_inline_comment_store(self.git_provider) store.load() if store.load_failed: - get_logger().warning("Inline key-issue publishing cannot verify existing Azure DevOps threads; " + get_logger().warning("Inline key-issue publishing cannot verify existing provider comments; " "keeping findings in the review summary") return data remaining_issues = [] @@ -1190,9 +1190,15 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: if location_fingerprint in candidate_comments: candidate_issues[location_fingerprint].append(issue) continue + max_chars = next( + (getattr(self.git_provider, attr) for attr in + ("max_comment_chars", "max_comment_length") + if isinstance(getattr(self.git_provider, attr, None), int)), + None, + ) comment["body"] = key_issue_body_with_markers( comment["body"], fingerprint, location_fingerprint, - getattr(self.git_provider, "max_comment_chars", None)) + max_chars, self.git_provider) candidate_comments[location_fingerprint] = comment candidate_issues[location_fingerprint] = [issue] candidate_fingerprints[location_fingerprint] = fingerprint @@ -1210,7 +1216,7 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: "end_line": comment["relevant_lines_end"]} for comment in candidate_comments.values()] get_logger().warning( - f"Failed to publish review findings as Azure DevOps threads, error: {e}", + f"Failed to publish review findings as inline comments, error: {e}", artifact={"locations": locations}) verified_locations = self._published_inline_key_issue_fingerprints(store, set(candidate_comments)) for location_fingerprint, comment in candidate_comments.items(): @@ -1220,7 +1226,7 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: store.add(location_fingerprint) published += len(issues_for_location) continue - get_logger().warning("Failed to publish a review finding as an Azure DevOps inline comment, " + get_logger().warning("Failed to publish a review finding as an inline comment, " "keeping it in the summary", artifact={"relevant_file": comment["relevant_file"], "start_line": comment["relevant_lines_start"], diff --git a/tests/unittest/test_bitbucket_inline_comment_dedup.py b/tests/unittest/test_bitbucket_inline_comment_dedup.py new file mode 100644 index 0000000000..3249ba2e92 --- /dev/null +++ b/tests/unittest/test_bitbucket_inline_comment_dedup.py @@ -0,0 +1,64 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from pr_agent.algo.inline_comment_dedup import ( + InlineCommentStore, + can_verify_inline_comment_publication, + key_issue_body_with_markers, +) +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider + + +def _provider(comment_bodies): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider._published_inline_comment_bodies = [] + provider.pr = MagicMock() + provider.pr.comments.return_value = [SimpleNamespace(raw=body) for body in comment_bodies] + return provider + + +def test_bitbucket_cloud_exposes_inline_dedup_capabilities(): + provider = _provider(["old inline finding"]) + + assert can_verify_inline_comment_publication(provider) + assert provider.get_persistent_comment_bodies() == ["old inline finding"] + assert provider.get_recent_inline_comment_bodies() == [] + + +def test_store_loads_bitbucket_persistent_bodies_without_failure(): + provider = _provider(["[pr-agent-dedup: abcdef123456]: https://github.com/The-PR-Agent/pr-agent"]) + store = InlineCommentStore(provider) + store.load() + assert not store.load_failed + assert store.seen("abcdef123456") + + +def test_bitbucket_cloud_dedup_bodies_include_published_comments(): + provider = _provider([]) + provider._published_inline_comment_bodies.append("new inline finding") + + assert provider.get_persistent_comment_bodies() == ["new inline finding"] + assert provider.get_recent_inline_comment_bodies() == ["new inline finding"] + + +def test_bitbucket_publish_records_body_and_preserves_markers(): + provider = _provider([]) + provider.headers = {} + provider.bitbucket_comment_api_url = "https://bitbucket.example/comments" + provider.max_comment_length = 31000 + body = key_issue_body_with_markers( + "x" * 31_500, "abcdef123456", "123456abcdef", 31_000, provider + ) + with patch("pr_agent.git_providers.bitbucket_provider.requests.request") as request: + request.return_value.raise_for_status.return_value = None + assert provider.publish_inline_comment(body, "file.py", 10) + published = provider.get_recent_inline_comment_bodies() + assert published and "pr-agent-key-issue-location: 123456abcdef" in published[0] + assert provider.get_persistent_comment_bodies() == published + + +def test_bitbucket_cloud_skips_comments_without_raw_body(): + provider = _provider([]) + provider.pr.comments.return_value = [SimpleNamespace(raw=None), SimpleNamespace(raw="valid")] + + assert provider.get_persistent_comment_bodies() == ["valid"] diff --git a/tests/unittest/test_inline_comment_dedup.py b/tests/unittest/test_inline_comment_dedup.py index afab18d5e1..f494ceddf3 100644 --- a/tests/unittest/test_inline_comment_dedup.py +++ b/tests/unittest/test_inline_comment_dedup.py @@ -76,6 +76,29 @@ def test_build_markers(): assert "" in out +def test_build_markers_uses_bitbucket_hidden_form(): + provider = MagicMock() + provider.supports_html_comment_markers.return_value = False + + out = d.build_markers("aaaaaaaaaaaa", "bbbbbbbbbbbb", provider) + + assert "" out = d.build_markers("aaaaaaaaaaaa", "bbbbbbbbbbbb") diff --git a/tests/unittest/test_pr_code_suggestions_core.py b/tests/unittest/test_pr_code_suggestions_core.py index d00fc647d0..a4aa36507a 100644 --- a/tests/unittest/test_pr_code_suggestions_core.py +++ b/tests/unittest/test_pr_code_suggestions_core.py @@ -1188,6 +1188,26 @@ async def test_suggestion_covering_the_anchored_range_is_published_as_committabl assert "```suggestion\n return new()\n```" in _published_suggestion(git_provider)["body"] +@pytest.mark.asyncio +async def test_aligned_original_suggestion_matches_rendered_fence_indentation(): + git_provider = _provider_with_file("def f():\n return old()\n") + tool = _make_tool(git_provider) + + await tool.push_inline_code_suggestions({"code_suggestions": [ + _valid_suggestion( + relevant_lines_start=2, + relevant_lines_end=2, + existing_code="return old()", + improved_code="return new()", + score=8, + ) + ]}) + + original = _published_suggestion(git_provider)["original_suggestion"] + assert original["existing_code"] == " return old()" + assert original["improved_code"] == " return new()" + + @pytest.mark.asyncio @pytest.mark.parametrize("improved_code", ["return new(", "return await new()"]) async def test_invalid_python_replacement_is_published_as_a_pr_comment(improved_code): From af3aef8eab644130fb1c5960fa950795e8f789ae Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:14:56 +0545 Subject: [PATCH 159/238] fix(config): honor reasoning_effort for OpenAI-compatible endpoints (#3460) Co-authored-by: Ismael Martinez Ramos --- docs/docs/usage-guide/changing_a_model.md | 2 + .../usage-guide/configuration_reference.md | 1 + .../algo/ai_handlers/litellm_ai_handler.py | 81 +++++++-- pr_agent/settings/configuration.toml | 10 ++ tests/unittest/test_config_reference.py | 2 +- .../unittest/test_litellm_reasoning_effort.py | 169 ++++++++++++++++++ 6 files changed, 249 insertions(+), 16 deletions(-) diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 7b11cb07e6..32c7a2403e 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -795,6 +795,8 @@ reasoning_effort = "medium" # "none", "minimal", "low", "medium", "high", "xhigh With the OpenAI models that support reasoning effort (eg: gpt-5.6-terra), you can specify its reasoning effort via `config` section. The default value is `medium`. You can change it to any supported value based on your usage. Available values depend on the model and provider. +For a model served through an OpenAI-compatible endpoint that is not in the built-in [`SUPPORT_REASONING_EFFORT_MODELS`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py) list, add its ID to `config.additional_reasoning_effort_models`. The list is additive: built-in reasoning models keep receiving `config.reasoning_effort`, and IDs match exactly or through any provider prefix (e.g. `"deepseek-v4-flash-0731"` matches `"openai/deepseek-v4-flash-0731"`). When LiteLLM does not recognize the model, PR-Agent sets `allowed_openai_params = ["reasoning_effort"]` so the parameter reaches the endpoint. Note the default `"medium"` may be rejected by providers that accept a different subset (e.g. `"none"/"low"/"high"/"max"`); adding a custom model ID surfaces that provider-side error instead of silently dropping the setting. + To use [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra): ```toml diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index d517741303..e63a101d4f 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -116,6 +116,7 @@ to-do list. | `enable_ai_metadata` | false | will enable adding ai metadata | | `add_user_to_requests` | false | send the current command and PR URL in the OpenAI-compatible "user" request field, for provider-side attribution of requests (e.g. OpenRouter "external_user") | | `reasoning_effort` | "medium" | "none", "minimal", "low", "medium", "high", "xhigh", "max" | +| `additional_reasoning_effort_models` | [] | Optional: additional model IDs that accept config.reasoning_effort. Appends to the built-in SUPPORT_REASONING_EFFORT_MODELS list (pr_agent/algo/__init__.py) instead of replacing it, so fallback_models mixing a custom endpoint model with a built-in reasoning model keep receiving the configured effort. Model IDs match exactly or through any provider prefix (e.g. "deepseek-v4-flash-0731" matches "openai/deepseek-v4-flash-0731"). LiteLLM whitelists reasoning_effort through allowed_openai_params for OpenAI-compatible models it does not recognize, so the parameter reaches the endpoint. The default "medium" may be rejected by providers that accept a different subset (e.g. "none"/"low"/"high"/"max"); adding a custom model id now surfaces a provider-side error instead of the previous silent drop. | **extended thinking for Claude reasoning models** | Key | Default | Description | diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 01e2686210..040eb56d4f 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -1921,6 +1921,30 @@ def _as_list(value) -> list: return [] +def _coerce_string_list_config(value): + """Return a list-like config value while accepting env-style strings.""" + if not value: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + if isinstance(value, str): + stripped_value = value.strip() + if not stripped_value: + return [] + if stripped_value.startswith("[") and stripped_value.endswith("]"): + try: + parsed_value = json.loads(stripped_value) + except json.JSONDecodeError: + return None + if isinstance(parsed_value, list): + return parsed_value + return None + return [model.strip() for model in stripped_value.split(",") if model.strip()] + return None + + def _configured_client_retries(): """config.num_retries as a non-negative int, or None (unset/invalid = client defaults). @@ -2169,8 +2193,30 @@ def __init__(self): # Model that doesn't support temperature argument self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS - # Models that support reasoning effort - self.support_reasoning_models = SUPPORT_REASONING_EFFORT_MODELS + # Append config-listed models to the built-in reasoning-effort list + additional_reasoning_models = _coerce_string_list_config( + get_settings().config.get("additional_reasoning_effort_models", []) + ) + if additional_reasoning_models is None: + get_logger().warning( + "Invalid additional_reasoning_effort_models in config; expected a list of model names. " + "Falling back to the built-in reasoning-effort model list." + ) + additional_reasoning_models = [] + elif additional_reasoning_models and not all( + isinstance(model, str) and model.strip() for model in additional_reasoning_models + ): + get_logger().warning( + "Invalid additional_reasoning_effort_models in config; " + "expected a list of model name strings. " + "Falling back to the built-in reasoning-effort model list." + ) + additional_reasoning_models = [] + # Store stripped names so exact-match checks against the model succeed even when the + # config entries contain surrounding whitespace (validation above already used strip()). + self.support_reasoning_models = SUPPORT_REASONING_EFFORT_MODELS + [ + model.strip() for model in additional_reasoning_models + ] # Models that support extended thinking (config override replaces the built-in list when non-empty) override = get_settings().config.get("claude_extended_thinking_models_override", []) or [] @@ -4084,7 +4130,9 @@ async def _chat_completion_with_retry( # configured reasoning_effort is not silently dropped for models the # user references with a provider prefix. OpenRouter routing variants # such as :nitro and :floor are stripped only for this membership test. - if any( + # Skip GPT-5/GPT-6 Astra here so a model registered via config cannot + # overwrite the reasoning_effort normalization of its dedicated branch. + if not (is_gpt5_model or is_gpt6_astra) and any( reasoning_model == m or reasoning_model.endswith("/" + m) for m in self.support_reasoning_models ): @@ -4099,18 +4147,21 @@ async def _chat_completion_with_retry( else: get_logger().info(f"Adding reasoning_effort with value {reasoning_effort} to model {model}.") kwargs["reasoning_effort"] = reasoning_effort - if self._grok_reasoning_levels_for(model): - try: - supported_params = litellm.get_supported_openai_params( - model=model, - custom_llm_provider=custom_llm_provider or None, - ) or [] - except Exception: - supported_params = [] - # LiteLLM may omit reasoning_effort for unknown or - # OpenAI-compatible gateway-prefixed Grok IDs. - if "reasoning_effort" not in supported_params: - kwargs["allowed_openai_params"] = ["reasoning_effort"] + # Whitelist reasoning_effort through allowed_openai_params when + # LiteLLM omits it from the params it reports for unknown or + # OpenAI-compatible gateway-prefixed model IDs. Merge into any + # existing allowed_openai_params instead of overwriting it. + try: + supported_params = litellm.get_supported_openai_params( + model=model, + custom_llm_provider=custom_llm_provider or None, + ) or [] + except Exception: + supported_params = [] + if "reasoning_effort" not in supported_params: + allowed_params = kwargs.get("allowed_openai_params") or [] + if "reasoning_effort" not in allowed_params: + kwargs["allowed_openai_params"] = [*allowed_params, "reasoning_effort"] # https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking adaptive_thinking_enabled = self._claude_thinking_controls["enable_claude_adaptive_thinking"] diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index d84aa5d040..415ed293a1 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -98,6 +98,16 @@ propagate_tool_errors = false # when true, a tool re-raises instead of swallowin enable_ai_metadata = false # will enable adding ai metadata add_user_to_requests = false # send the current command and PR URL in the OpenAI-compatible "user" request field, for provider-side attribution of requests (e.g. OpenRouter "external_user") reasoning_effort = "medium" # "none", "minimal", "low", "medium", "high", "xhigh", "max" +# Optional: additional model IDs that accept config.reasoning_effort. Appends to the built-in +# SUPPORT_REASONING_EFFORT_MODELS list (pr_agent/algo/__init__.py) instead of replacing it, so +# fallback_models mixing a custom endpoint model with a built-in reasoning model keep receiving +# the configured effort. Model IDs match exactly or through any provider prefix +# (e.g. "deepseek-v4-flash-0731" matches "openai/deepseek-v4-flash-0731"). LiteLLM whitelists +# reasoning_effort through allowed_openai_params for OpenAI-compatible models it does not +# recognize, so the parameter reaches the endpoint. The default "medium" may be rejected by +# providers that accept a different subset (e.g. "none"/"low"/"high"/"max"); adding a custom +# model id now surfaces a provider-side error instead of the previous silent drop. +additional_reasoning_effort_models = [] # extended thinking for Claude reasoning models enable_claude_extended_thinking = false # Set to true to enable extended thinking feature extended_thinking_budget_tokens = 2048 diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index 750c2a6d7e..a7b8c34548 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 265 + assert sum(keys.values()) == 266 assert { "model", diff --git a/tests/unittest/test_litellm_reasoning_effort.py b/tests/unittest/test_litellm_reasoning_effort.py index 4a3b31e758..7b88d66c23 100644 --- a/tests/unittest/test_litellm_reasoning_effort.py +++ b/tests/unittest/test_litellm_reasoning_effort.py @@ -1299,3 +1299,172 @@ async def test_openrouter_grok_invalid_override_falls_back_to_global(self, monke ) assert call_kwargs["extra_body"]["reasoning"] == {"effort": "high"} + + +class TestAdditionalReasoningEffortModels: + """Verify config.additional_reasoning_effort_models opts custom OpenAI-compatible + model IDs into config.reasoning_effort. + + Keep the override additive so built-in SUPPORT_REASONING_EFFORT_MODELS entries stay + active and a fallback_models chain mixing a custom endpoint with a built-in reasoning + model keeps receiving the configured effort. When LiteLLM does not recognize the model, + whitelist reasoning_effort through allowed_openai_params so the endpoint receives it. + """ + + def _settings(self, additional): + """Build settings whose config.get resolves additional_reasoning_effort_models.""" + settings = create_mock_settings("low") + settings.config.get = lambda key, default=None: { + "additional_reasoning_effort_models": additional, + }.get(key, default) + return settings + + def _isolate_env(self, monkeypatch): + # LiteLLMAIHandler.__init__ branches on these; clear them for a deterministic handler. + for name in ("AWS_USE_IMDS", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", "AWS_REGION_NAME", "OPENAI_API_KEY"): + monkeypatch.delenv(name, raising=False) + + @pytest.mark.asyncio + async def test_custom_openai_model_forwards_reasoning_effort(self, monkeypatch, mock_logger): + """Forward reasoning_effort to a config-registered model id and whitelist it when + LiteLLM reports no support (gate 2 regression from issue #3459).""" + fake_settings = self._settings(additional=["deepseek-v4-flash-0731"]) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: []) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="openai/deepseek-v4-flash-0731", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert kwargs["reasoning_effort"] == "low" + assert kwargs["allowed_openai_params"] == ["reasoning_effort"] + + @pytest.mark.asyncio + async def test_custom_model_keeps_native_litellm_support(self, monkeypatch, mock_logger): + """Avoid forcing the allowlist when LiteLLM already lists reasoning_effort for the model.""" + fake_settings = self._settings(additional=["deepseek-v4-flash-0731"]) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: ["reasoning_effort"]) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="openai/deepseek-v4-flash-0731", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert kwargs["reasoning_effort"] == "low" + assert "allowed_openai_params" not in kwargs + + @pytest.mark.asyncio + async def test_override_is_additive(self, monkeypatch, mock_logger): + """Preserve reasoning_effort for built-in models when the override adds a custom id.""" + fake_settings = self._settings(additional=["deepseek-v4-flash-0731"]) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: []) + self._isolate_env(monkeypatch) + + for model in ( + "openai/deepseek-v4-flash-0731", + "gemini/gemini-2.5-pro", + ): + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion(model=model, system="system", user="user") + + kwargs = completion.call_args.kwargs + assert kwargs["reasoning_effort"] == "low", f"failed for {model}" + + @pytest.mark.asyncio + @pytest.mark.parametrize("additional", [ + {"model": "deepseek-v4-flash-0731"}, + ["deepseek-v4-flash-0731", 123], + [""], + ]) + async def test_invalid_override_falls_back_to_builtin_list(self, monkeypatch, mock_logger, additional): + """Reject an unsupported override with a warning and fall back to the built-in list.""" + fake_settings = self._settings(additional=additional) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: []) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="openai/deepseek-v4-flash-0731", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert "reasoning_effort" not in kwargs + assert any( + "additional_reasoning_effort_models" in call.args[0] + for call in mock_logger.warning.call_args_list + ) + + @pytest.mark.asyncio + async def test_env_string_override_forwards_reasoning_effort(self, monkeypatch, mock_logger): + """Accept an env-style string override and forward reasoning_effort for its model.""" + fake_settings = self._settings(additional="deepseek-v4-flash-0731") + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: []) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="openai/deepseek-v4-flash-0731", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert kwargs["reasoning_effort"] == "low" + assert kwargs["allowed_openai_params"] == ["reasoning_effort"] + + @pytest.mark.asyncio + @pytest.mark.parametrize(("configured", "expected"), [("none", "low"), ("minimal", "low")]) + async def test_astra_normalization_survives_override(self, monkeypatch, mock_logger, configured, expected): + """Keep the GPT-6 Astra none/minimal -> low conversion when gpt-6-astra is added via config.""" + settings = create_mock_settings(configured) + settings.config.get = lambda key, default=None: { + "additional_reasoning_effort_models": ["gpt-6-astra"], + }.get(key, default) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: settings) + monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: []) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="gpt-6-astra", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert kwargs["reasoning_effort"] == expected + assert kwargs["allowed_openai_params"] == ["reasoning_effort"] + + @pytest.mark.asyncio + async def test_gpt5_max_normalization_survives_override(self, monkeypatch, mock_logger): + """Keep the GPT-5 max -> xhigh conversion when a gpt-5 model is added via config.""" + settings = create_mock_settings("max") + settings.config.get = lambda key, default=None: { + "additional_reasoning_effort_models": ["gpt-5.6"], + }.get(key, default) + monkeypatch.setattr(litellm_handler, "get_settings", lambda: settings) + monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: []) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="gpt-5.6", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert kwargs["reasoning_effort"] == "xhigh" + assert "temperature" not in kwargs + assert kwargs["allowed_openai_params"] == ["reasoning_effort"] From 2f843f06dbced05c605983320a9b0f1e3dd2626e Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:30:27 +0545 Subject: [PATCH 160/238] fix(github): pass event=COMMENT to create_review so inline reviews are not left PENDING (#3482) --- pr_agent/git_providers/github_provider.py | 4 ++-- pr_agent/tools/pr_update_changelog.py | 2 +- tests/unittest/test_github_provider_comments.py | 6 +++--- tests/unittest/test_pr_update_changelog.py | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 706be3752c..c36014b53d 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -794,7 +794,7 @@ def publish_inline_comments(self, comments: list[dict], disable_fallback: bool = ] try: # publish all comments in a single message - self.pr.create_review(commit=self.last_commit_id, comments=comments) + self.pr.create_review(commit=self.last_commit_id, event="COMMENT", comments=comments) # The whole batch posted; record its fingerprints so the rest of this # run dedups against them. Cross-run dedup relies on the markers in the # posted bodies, so comments the fallback below drops stay unrecorded @@ -984,7 +984,7 @@ def _publish_inline_comments_fallback_with_verification(self, comments: list[dic # publish as a group the verified comments if verified_comments: - self.pr.create_review(commit=self.last_commit_id, comments=verified_comments) + self.pr.create_review(commit=self.last_commit_id, event="COMMENT", comments=verified_comments) published_count += len(verified_comments) # try to publish one by one the invalid comments as a one-line code comment diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index 6597e2cb6f..fef094e832 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -238,7 +238,7 @@ def _push_changelog_update(self, new_file_content, answer): line=max(2, len(answer.splitlines())), start_line=1, ) - self.git_provider.pr.create_review(commit=last_commit_id, comments=[d]) + self.git_provider.pr.create_review(commit=last_commit_id, event="COMMENT", comments=[d]) except Exception: # we can't create a review for some reason, let's just publish a comment self.git_provider.publish_comment(f"**Changelog updates: 🔄**\n\n{answer}") diff --git a/tests/unittest/test_github_provider_comments.py b/tests/unittest/test_github_provider_comments.py index 599704c694..513b03ad31 100644 --- a/tests/unittest/test_github_provider_comments.py +++ b/tests/unittest/test_github_provider_comments.py @@ -33,9 +33,9 @@ def __init__(self, raise_on_first=None): self._raise_on_first = raise_on_first self._calls = 0 - def create_review(self, commit=None, comments=None): + def create_review(self, commit=None, event=None, comments=None): self._calls += 1 - self.create_review_calls.append({"commit": commit, "comments": comments}) + self.create_review_calls.append({"commit": commit, "event": event, "comments": comments}) if self._raise_on_first is not None and self._calls == 1: exc = self._raise_on_first self._raise_on_first = None @@ -570,7 +570,7 @@ def test_publish_code_suggestions_422_fallback_repaired_comment_failure_returns_ calls = 0 - def fail_repaired(commit=None, comments=None): + def fail_repaired(commit=None, event=None, comments=None): nonlocal calls calls += 1 if calls == 1: diff --git a/tests/unittest/test_pr_update_changelog.py b/tests/unittest/test_pr_update_changelog.py index d7cf0ca878..42de474a8e 100644 --- a/tests/unittest/test_pr_update_changelog.py +++ b/tests/unittest/test_pr_update_changelog.py @@ -277,6 +277,7 @@ def test_push_changelog_update_creates_review_when_supported(self, changelog_too ) mock_git_provider.pr.create_review.assert_called_once_with( commit="commit-123", + event="COMMENT", comments=[ dict( body="CHANGELOG.md update", From 475309366e47359ab55968586ac0ef5a5b82c347 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:02:03 +0800 Subject: [PATCH 161/238] chore(ci): migrate Release Drafter to v7 (#3484) --- .github/workflows/release-drafter.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 58be517edd..934f73d008 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -20,6 +20,11 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@6a93d829887aa2e0748befe2e808c66c0ec6e4c7 # v6.4.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: github.event_name == 'push' + uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - if: github.event_name == 'pull_request_target' + uses: release-drafter/release-drafter/autolabeler@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} From 7265e5cd92f5032b47d768b19b38f05ccf198b49 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 18 Sep 2026 13:04:47 -0500 Subject: [PATCH 162/238] fix(azure): preserve original content for renamed files (#3465) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- .../git_providers/azuredevops_provider.py | 61 +++- tests/unittest/test_azure_devops_provider.py | 295 ++++++++++++++++-- 2 files changed, 321 insertions(+), 35 deletions(-) diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index d49ca39944..81603b0d21 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -135,6 +135,43 @@ def _get_azure_change_path(change): return getattr(item, "path", None) +def _get_azure_change_old_path(change): + additional = getattr(change, "additional_properties", None) + if not isinstance(additional, dict): + additional = {} + + for attribute, serialized_attribute in ( + ("original_path", "originalPath"), + ("source_server_item", "sourceServerItem"), + ): + values = ( + getattr(change, attribute, None), + getattr(change, serialized_attribute, None), + additional.get(serialized_attribute), + additional.get(attribute), + ) + for value in values: + if isinstance(value, str) and value.strip(): + return value + return None + + +def _get_azure_change_type(change): + additional = getattr(change, "additional_properties", None) + if not isinstance(additional, dict): + additional = {} + + for value in ( + getattr(change, "change_type", None), + getattr(change, "changeType", None), + additional.get("changeType"), + additional.get("change_type"), + ): + if isinstance(value, str) and value.strip(): + return value + return "Unknown" + + class AzureDevopsProvider(GitProvider): _INCREMENTAL_ANCHOR_PREFIXES = { @@ -857,11 +894,15 @@ def get_diff_files(self) -> list[FilePatchInfo]: diff_files = [] diffs = [] diff_types = {} + old_paths = {} for change in self._get_pr_iteration_changes(): path = _get_azure_change_path(change) if path: diffs.append(path) - diff_types[path] = change.additional_properties.get("changeType", "Unknown") + change_type = _get_azure_change_type(change) + diff_types[path] = change_type + if "rename" in change_type: + old_paths[path] = _get_azure_change_old_path(change) # wrong implementation - gets all the files that were changed in any commit in the PR # commits = self.azure_devops_client.get_pull_request_commits( @@ -944,7 +985,13 @@ def get_diff_files(self) -> list[FilePatchInfo]: elif "rename" in diff_types[file]: # diff_type can be `rename` | `edit, rename` edit_type = EDIT_TYPE.RENAMED - if edit_type == EDIT_TYPE.ADDED or edit_type == EDIT_TYPE.RENAMED: + old_filename = old_paths.get(file) if edit_type == EDIT_TYPE.RENAMED else None + if edit_type == EDIT_TYPE.RENAMED and old_filename is None: + get_logger().warning( + f"Azure rename entry for {file} has no usable old path; using empty base content" + ) + + if edit_type == EDIT_TYPE.ADDED or (edit_type == EDIT_TYPE.RENAMED and old_filename is None): original_file_content_str = "" elif incremental_active: inc_version = GitVersionDescriptor( @@ -953,7 +1000,7 @@ def get_diff_files(self) -> list[FilePatchInfo]: try: inc_original = self.azure_devops_client.get_item( repository_id=self.repo_slug, - path=file, + path=old_filename or file, project=self.workspace_slug, version_descriptor=inc_version, download=False, @@ -962,7 +1009,8 @@ def get_diff_files(self) -> list[FilePatchInfo]: original_file_content_str = inc_original.content or "" except Exception as error: get_logger().warning( - f"Failed to retrieve original of {file} at {self.incremental.last_seen_commit_sha}: {error}" + f"Failed to retrieve original of {old_filename or file} " + f"at {self.incremental.last_seen_commit_sha}: {error}" ) original_file_content_str = "" else: @@ -972,7 +1020,7 @@ def get_diff_files(self) -> list[FilePatchInfo]: try: base_original = self.azure_devops_client.get_item( repository_id=self.repo_slug, - path=file, + path=old_filename or file, project=self.workspace_slug, version_descriptor=base_version, download=False, @@ -982,7 +1030,7 @@ def get_diff_files(self) -> list[FilePatchInfo]: except Exception as error: get_logger().error( "Failed to retrieve original file content of {file} at version {version}", - file=file, + file=old_filename or file, version=str(base_version), error=error, ) @@ -1006,6 +1054,7 @@ def get_diff_files(self) -> list[FilePatchInfo]: patch=patch, filename=file, edit_type=edit_type, + old_filename=old_filename, num_plus_lines=num_plus_lines, num_minus_lines=num_minus_lines, ) diff --git a/tests/unittest/test_azure_devops_provider.py b/tests/unittest/test_azure_devops_provider.py index c46c7d0e9c..d8533a380f 100644 --- a/tests/unittest/test_azure_devops_provider.py +++ b/tests/unittest/test_azure_devops_provider.py @@ -5,13 +5,14 @@ import pytest from pr_agent.algo.inline_comment_dedup import code_fingerprint -from pr_agent.algo.types import FilePatchInfo +from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.algo.utils import PRCodeSuggestionsIdentity from pr_agent.git_providers.azuredevops_provider import ( AzureDevopsProvider, Comment, CommentThread, ) +from pr_agent.git_providers.git_provider import IncrementalPR from pr_agent.log import get_logger @@ -218,11 +219,12 @@ def test_get_repo_file_content_propagates_non_404_errors(self): class TestAzureDevopsProviderFiles: @staticmethod - def _change(path, change_type="edit", git_object_type="blob"): + def _change(path="/src/app.py", change_type="edit", git_object_type="blob", **metadata): return SimpleNamespace( additional_properties={ "item": {"path": path, "gitObjectType": git_object_type}, "changeType": change_type, + **metadata, } ) @@ -365,35 +367,19 @@ def test_get_files_uses_current_iteration_and_skips_non_file_entries(self): provider.azure_devops_client.get_pull_request_commits.assert_not_called() provider.azure_devops_client.get_changes.assert_not_called() + @classmethod + def _provider_with_change(cls, change, *get_item_results, incremental=None): + provider = cls._provider([ + SimpleNamespace(change_entries=[change], next_skip=0, next_top=0), + ]) + provider.azure_devops_client.get_item.side_effect = get_item_results + provider.incremental = incremental + return provider + @staticmethod def _provider_with_pull_request_diff(*get_item_results): - provider = AzureDevopsProvider.__new__(AzureDevopsProvider) - provider.repo_slug = "my-repo" - provider.workspace_slug = "my-project" - provider.pr_num = 1 - provider.pr = SimpleNamespace( - last_merge_target_commit=SimpleNamespace(commit_id="base-sha"), - last_merge_commit=SimpleNamespace(commit_id="head-sha"), - ) - provider.azure_devops_client = MagicMock() - client = provider.azure_devops_client - client.get_pull_request_iterations.return_value = [SimpleNamespace(id=1)] - client.get_pull_request_iteration_changes.return_value = SimpleNamespace( - change_entries=[ - SimpleNamespace( - additional_properties={ - "item": {"path": "/src/app.py"}, - "changeType": "edit", - } - ) - ] - ) - client.get_item.side_effect = get_item_results - provider.diff_files = None - provider._pr_iteration_changes_cache = None - provider.incremental = None - provider.unreviewed_files_map = {} - return provider + change = TestAzureDevopsProviderFiles._change() + return TestAzureDevopsProviderFiles._provider_with_change(change, *get_item_results) def test_get_diff_files_keeps_file_when_new_content_fetch_fails(self): provider = self._provider_with_pull_request_diff( @@ -433,6 +419,257 @@ def test_get_diff_files_keeps_file_when_original_content_fetch_fails(self): assert diff_files[0].base_file == "" assert any("/src/app.py" in message and "base-sha" in message for message in captured) + def test_pure_rename_uses_old_path_and_preserves_identity(self): + change = self._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ) + provider = self._provider_with_change( + change, + SimpleNamespace(content="same content\n"), + SimpleNamespace(content="same content\n"), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.filename == "/new/name.py" + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == diff_file.head_file == "same content\n" + assert diff_file.patch == "" + assert diff_file.num_plus_lines == 0 + assert diff_file.num_minus_lines == 0 + calls = provider.azure_devops_client.get_item.call_args_list + assert calls[0].kwargs["path"] == "/new/name.py" + assert calls[0].kwargs["version_descriptor"].version == "head-sha" + assert calls[1].kwargs["path"] == "/old/name.py" + assert calls[1].kwargs["version_descriptor"].version == "base-sha" + + def test_rename_on_later_page_preserves_old_path_and_base_content(self): + provider = self._provider([ + SimpleNamespace( + change_entries=[self._change("/src/first.py")], + next_skip=1, + next_top=1, + ), + SimpleNamespace( + change_entries=[ + self._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ) + ], + next_skip=0, + next_top=0, + ), + ]) + + diff_files = provider.get_diff_files() + + assert [diff_file.filename for diff_file in diff_files] == ["/src/first.py", "/new/name.py"] + assert diff_files[1].old_filename == "/old/name.py" + assert diff_files[1].head_file == "content for /new/name.py\n" + assert diff_files[1].base_file == "content for /old/name.py\n" + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_count == 2 + + def test_rename_with_edit_only_reports_the_actual_change_and_keeps_head_anchor(self): + change = self._change( + path="/new/name.py", + change_type="edit, rename", + originalPath="/old/name.py", + ) + provider = self._provider_with_change( + change, + SimpleNamespace(content="value = 2\n"), + SimpleNamespace(content="value = 1\n"), + ) + + diff_file = provider.get_diff_files()[0] + inline_comment = provider.create_inline_comment("body", "/new/name.py", "value = 2") + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.num_plus_lines == 1 + assert diff_file.num_minus_lines == 1 + assert "+value = 2" in diff_file.patch + assert "-value = 1" in diff_file.patch + assert inline_comment["path"] == "/new/name.py" + assert inline_comment["subject_type"] == "LINE" + + @pytest.mark.parametrize( + ("change", "expected_old_path"), + [ + ( + SimpleNamespace( + original_path="/attribute/original.py", + source_server_item="/attribute/source.py", + additional_properties={ + "item": {"path": "/new/name.py"}, + "changeType": "rename", + "originalPath": "/mapping/original.py", + "sourceServerItem": "/mapping/source.py", + }, + ), + "/attribute/original.py", + ), + ( + SimpleNamespace( + original_path="", + source_server_item="/attribute/source.py", + additional_properties={ + "item": {"path": "/new/name.py"}, + "changeType": "rename", + "originalPath": "/mapping/original.py", + }, + ), + "/mapping/original.py", + ), + ( + SimpleNamespace( + additional_properties={ + "item": {"path": "/new/name.py"}, + "changeType": "rename", + "sourceServerItem": "/mapping/source.py", + }, + ), + "/mapping/source.py", + ), + ( + SimpleNamespace( + item=SimpleNamespace(path="/new/name.py"), + change_type="rename", + source_server_item="/attribute/source.py", + additional_properties={}, + ), + "/attribute/source.py", + ), + ], + ) + def test_rename_old_path_metadata_precedence(self, change, expected_old_path): + provider = self._provider_with_change( + change, + SimpleNamespace(content="new\n"), + SimpleNamespace(content="old\n"), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.old_filename == expected_old_path + assert provider.azure_devops_client.get_item.call_args_list[1].kwargs["path"] == expected_old_path + + @pytest.mark.parametrize( + "metadata", + [ + {}, + {"originalPath": {"path": "/old/name.py"}, "sourceServerItem": 17}, + {"originalPath": " ", "sourceServerItem": []}, + ], + ) + def test_rename_with_missing_or_malformed_old_path_keeps_empty_base_fallback(self, metadata): + change = self._change(path="/new/name.py", change_type="rename", **metadata) + provider = self._provider_with_change(change, SimpleNamespace(content="new\n")) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename is None + assert diff_file.base_file == "" + assert provider.azure_devops_client.get_item.call_count == 1 + assert any("/new/name.py" in message and "no usable old path" in message for message in captured) + + def test_incremental_rename_reads_old_path_at_last_seen_commit(self): + change = self._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ) + incremental = IncrementalPR(True) + incremental.last_seen_commit = SimpleNamespace(sha="last-seen-sha") + provider = self._provider_with_change( + change, + SimpleNamespace(content="new\n"), + SimpleNamespace(content="old\n"), + incremental=incremental, + ) + provider.unreviewed_files_map = {"/new/name.py": "/new/name.py"} + + diff_file = provider.get_diff_files()[0] + + assert diff_file.old_filename == "/old/name.py" + old_content_call = provider.azure_devops_client.get_item.call_args_list[1] + assert old_content_call.kwargs["path"] == "/old/name.py" + assert old_content_call.kwargs["version_descriptor"].version == "last-seen-sha" + assert provider.unreviewed_files_map["/new/name.py"] == diff_file.patch + + def test_failed_rename_old_content_read_keeps_identity_and_logs_the_failure(self): + change = self._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ) + provider = self._provider_with_change( + change, + SimpleNamespace(content="new\n"), + Exception("base fetch failed"), + ) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.base_file == "" + assert any("/old/name.py" in message and "base-sha" in message for message in captured) + + @pytest.mark.parametrize( + ("change_type", "get_item_results", "expected_type", "expected_base", "expected_paths"), + [ + ( + "add", + (SimpleNamespace(content="new\n"),), + EDIT_TYPE.ADDED, + "", + ["/src/app.py"], + ), + ( + "edit", + (SimpleNamespace(content="new\n"), SimpleNamespace(content="old\n")), + EDIT_TYPE.MODIFIED, + "old\n", + ["/src/app.py", "/src/app.py"], + ), + ( + "delete", + (Exception("head missing"), SimpleNamespace(content="old\n")), + EDIT_TYPE.DELETED, + "old\n", + ["/src/app.py", "/src/app.py"], + ), + ], + ) + def test_non_rename_change_handling_is_unchanged( + self, change_type, get_item_results, expected_type, expected_base, expected_paths + ): + provider = self._provider_with_change( + self._change(change_type=change_type), + *get_item_results, + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.edit_type == expected_type + assert diff_file.old_filename is None + assert diff_file.base_file == expected_base + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == expected_paths + def _provider_with_diff(*filenames): provider = AzureDevopsProvider.__new__(AzureDevopsProvider) From 6939bfa6b92a2cdad65af15d71899fc95f951fc8 Mon Sep 17 00:00:00 2001 From: Artur <83003122+Nie1iX@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:05:05 +0300 Subject: [PATCH 163/238] feat(gitlab): content-based auto-resolve for fixed inline threads (#3478) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../usage-guide/configuration_reference.md | 1 + pr_agent/git_providers/gitlab_provider.py | 159 ++++++++++++ pr_agent/settings/configuration.toml | 6 + tests/unittest/test_config_reference.py | 2 +- tests/unittest/test_gitlab_fixed_threads.py | 230 ++++++++++++++++++ 5 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 tests/unittest/test_gitlab_fixed_threads.py diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index e63a101d4f..9f4558e40b 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -363,6 +363,7 @@ _This section only documents commented-out examples; see the [TOML source](https | `publish_improve_as_thread` | false | Post the /improve suggestions comment as a resolvable thread (discussion) instead of a plain note. | | `publish_code_suggestions_as_review` | false | When pr_code_suggestions.commitable_code_suggestions is true, queue each suggestion as a GitLab draft note and publish them all together in one batch (like GitLab's own "start a review" flow) instead of posting each as its own live discussion - and its own notification - as soon as it's created. | | `resolve_outdated_inline_threads` | false | Resolve the bot's own inline threads that a later push left on an outdated diff version. | +| `auto_resolve_fixed_inline_threads` | false | Resolve the bot's own inline threads whose flagged line was modified after the comment was posted - i.e. the diff between the comment's head sha and the current head sha removes/replaces that line. Unlike resolve_outdated_inline_threads this is content-based: threads on lines nobody touched (or merely shifted by unrelated insertions) stay open. | | `handle_push_trigger` | false | | | `push_commands` | ["/describe", "/review"] | | | `handle_reviewer_assignment` | false | Auto-trigger commands when the bot is assigned as a reviewer on an MR | diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 66ce5844e1..3e846f633c 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -73,6 +73,66 @@ def _parse_gitlab_iso_datetime(value) -> Optional[datetime]: return None +def _removed_lines_from_patch(patch: str) -> set: + """Line numbers in the base file that a unified-diff patch removes.""" + removed = set() + base_line = None + for raw in (patch or "").splitlines(): + match = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", raw) + if match: + base_line = int(match.group(1)) + continue + if base_line is None: + continue # ---/+++ file headers arrive before the first hunk + if raw.startswith("\\"): + continue # "\ No newline at end of file" metadata is not a file line + if raw.startswith("-"): + removed.add(base_line) + base_line += 1 + elif not raw.startswith("+"): + base_line += 1 + return removed + + +def _eligible_own_inline_thread(discussion, own_user_id: int): + """Position dict of a bot-owned, open, resolvable text thread, else None.""" + notes = discussion.attributes.get('notes') or [] + if not notes or not isinstance(notes[0], dict): + return None + opener = notes[0] + if opener.get('resolved') or opener.get('resolvable') is False: + return None + position = opener.get('position') + if not isinstance(position, dict) or position.get('position_type') != 'text': + return None + if not is_agent_inline_comment(opener.get('body')): + return None + for note in notes: + if not isinstance(note, dict): + return None + if note.get('system'): + continue + author = note.get('author') + author_id = author.get('id') if isinstance(author, dict) else None + if author_id != own_user_id: + return None + return position + + +def _flagged_line_removed(position: dict, removed_lines: dict) -> bool: + # The compare base is the comment's head sha, so base coordinates are the + # comment-time coordinates: a flagged line resolves only when that exact + # line was removed/replaced. Lines merely shifted by insertions stay open. + # Deletion-anchored comments carry only old_line - a coordinate in the MR + # base, not the comment's head - so they cannot be checked and stay open. + if position.get('new_line') is None: + return False + path = position.get('new_path') + if not path: + return False + return position['new_line'] in (removed_lines.get(path) or set()) + + def _is_outdated_own_inline_thread(discussion, own_user_id: int, current_head_sha: str) -> bool: notes = discussion.attributes.get('notes') or [] if not notes or not isinstance(notes[0], dict): @@ -994,6 +1054,12 @@ def supports_review_comment_identity(self) -> bool: def supports_review_finding_state(self) -> bool: return True + def supports_code_suggestion_state(self) -> bool: + return True + + def get_code_suggestion_thread_context(self) -> str: + return "" + def is_comment_authored_by_pr_agent(self, comment) -> bool: if isinstance(comment, dict): author = comment.get("author") or comment.get("user") @@ -1114,6 +1180,98 @@ def resolve_outdated_inline_threads(self): get_logger().info( f"Resolved {resolved} outdated inline thread(s) on merge request {self.id_mr}") + def _removed_lines_since(self, base_sha: str, head_sha: str) -> dict: + """Base-side line numbers removed per path between two commits, via repository_compare.""" + removed = {} + try: + project = self.gl.projects.get(self.id_project) + # straight=True: direct base..head comparison. The default merge-base + # comparison reports lines dropped by a rebase/merge as removed, which + # would resolve threads whose flagged code still exists at head. + comparison = project.repository_compare(base_sha, head_sha, straight=True) + except Exception as e: + get_logger().warning( + f"Could not compare {base_sha[:12]}..{head_sha[:12]} for fixed-thread detection: {e}") + return removed + if isinstance(comparison, dict): + diffs = comparison.get('diffs', []) or [] + else: + diffs = getattr(comparison, 'diffs', []) or [] + for diff in diffs: + # Compare entries are dicts in practice; normalize object-shaped + # responses the same way the incremental-review path does. + if not isinstance(diff, dict): + diff = {key: getattr(diff, key, None) + for key in ('new_path', 'old_path', 'diff')} + path = diff.get('old_path') or diff.get('new_path') + if path: + removed.setdefault(path, set()).update(_removed_lines_from_patch(diff.get('diff'))) + return removed + + def reconcile_code_suggestion_threads(self) -> int: + if not get_settings().get("GITLAB.AUTO_RESOLVE_FIXED_INLINE_THREADS", False): + return 0 + if getattr(self, '_fixed_threads_swept', False): + return 0 # one sweep per process: repeats only burn API calls + own_user_id = self._get_own_user_id() + try: + current_head_sha = self.mr.diff_refs['head_sha'] + except (KeyError, TypeError, AttributeError): + current_head_sha = None + if own_user_id is None or not current_head_sha: + get_logger().warning( + f"Skipping fixed inline thread cleanup on merge request {self.id_mr} " + f"(bot user: {own_user_id}, current head sha: {current_head_sha})" + ) + return 0 + try: + discussions = self.mr.discussions.list(get_all=True) + except Exception as e: + get_logger().warning(f"Failed to list discussions of merge request {self.id_mr}: {e}") + return 0 + self._fixed_threads_swept = True + # Threads pinned to the same head share one compare call. + removed_lines_cache = {} + + def removed_lines_for(position) -> dict: + recorded = position.get('head_sha') + if not recorded or recorded == current_head_sha: + return None # nothing pushed since the comment - nothing could be fixed yet + if recorded not in removed_lines_cache: + removed_lines_cache[recorded] = self._removed_lines_since(recorded, current_head_sha) + return removed_lines_cache[recorded] + + resolved = 0 + released_fps = set() + for discussion in discussions: + discussion_id = getattr(discussion, 'id', None) + try: + notes = discussion.attributes.get('notes') or [] + # Cheap eligibility guards first: ineligible discussions must not + # trigger a repository_compare call. + position = _eligible_own_inline_thread(discussion, own_user_id) + if position is None: + continue + removed_lines = removed_lines_for(position) + if removed_lines is None: + continue + if not _flagged_line_removed(position, removed_lines): + continue + discussion.resolved = True + discussion.save() + resolved += 1 + for note in notes: + if isinstance(note, dict): + released_fps |= marker_fingerprints(note.get('body')) + except Exception as e: + get_logger().warning(f"Failed to resolve fixed inline thread {discussion_id}: {e}") + if released_fps: + get_inline_comment_store(self).release(released_fps) + if resolved: + get_logger().info( + f"Resolved {resolved} fixed inline thread(s) on merge request {self.id_mr}") + return resolved + def edit_comment_from_comment_id(self, comment_id: int, body: str): body = self.limit_output_characters(body, self.max_comment_chars) comment = self.mr.notes.get(comment_id) @@ -1294,6 +1452,7 @@ def get_relevant_diff(self, relevant_file: str, relevant_line_in_file: str) -> O def publish_code_suggestions(self, code_suggestions: list) -> bool: # Runs first so the fingerprints it frees are in the store before any dedup lookup. self.resolve_outdated_inline_threads() + self.reconcile_code_suggestion_threads() # When true, suggestions are queued as GitLab draft notes and published together in a single # batch at the end, instead of each one going out as its own live discussion (and its own # notification/email) as soon as it's created. diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 415ed293a1..b175217657 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -340,6 +340,12 @@ publish_improve_as_thread = false publish_code_suggestions_as_review = false # Resolve the bot's own inline threads that a later push left on an outdated diff version. resolve_outdated_inline_threads = false +# Resolve the bot's own inline threads whose flagged line was modified after the +# comment was posted - i.e. the diff between the comment's head sha and the +# current head sha removes/replaces that line. Unlike resolve_outdated_inline_threads +# this is content-based: threads on lines nobody touched (or merely shifted by +# unrelated insertions) stay open. +auto_resolve_fixed_inline_threads = false handle_push_trigger = false push_commands = [ "/describe", diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index a7b8c34548..f530a103dc 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 266 + assert sum(keys.values()) == 267 assert { "model", diff --git a/tests/unittest/test_gitlab_fixed_threads.py b/tests/unittest/test_gitlab_fixed_threads.py new file mode 100644 index 0000000000..b13cde06f3 --- /dev/null +++ b/tests/unittest/test_gitlab_fixed_threads.py @@ -0,0 +1,230 @@ +"""Unit tests for gitlab.auto_resolve_fixed_inline_threads.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.gitlab_provider import ( + GitLabProvider, + _eligible_own_inline_thread, + _flagged_line_removed, + _removed_lines_from_patch, +) + +from ._settings_helpers import restore_settings, snapshot_settings + +BOT_ID = 71 +OLD_SHA = "a" * 40 +HEAD_SHA = "b" * 40 +AGENT_BODY = "**Suggestion:** drop the key " + +PATCH = """\ +@@ -1,3 +1,4 @@ + ctx +-old ++new one ++new two + ctx +@@ -10,2 +10,3 @@ + ctx ++added + ctx +""" + +INSERT_ONLY_PATCH = """\ +@@ -1,2 +1,3 @@ + ctx ++inserted + ctx +""" + +NO_NEWLINE_PATCH = """\ +@@ -1,3 +1,3 @@ + ctx +-old +\\ No newline at end of file ++new +\\ No newline at end of file +""" + +DOUBLE_DASH_PATCH = """\ +@@ -1,3 +1,3 @@ + ctx +---flag + ctx +""" + + +def _discussion(body=AGENT_BODY, path="app.py", line=4, head_sha=OLD_SHA, + resolved=False, author_id=BOT_ID): + note = { + "resolved": resolved, + "resolvable": True, + "body": body, + "author": {"id": author_id}, + "position": { + "position_type": "text", + "new_path": path, + "new_line": line, + "head_sha": head_sha, + }, + } + return SimpleNamespace(id="d1", attributes={"notes": [note]}, + resolved=False, save=MagicMock()) + + +def _provider(discussions, diffs) -> GitLabProvider: + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "owner/repo" + provider.id_mr = 7 + provider.mr = MagicMock() + provider.mr.diff_refs = {"head_sha": HEAD_SHA} + provider.mr.discussions.list.return_value = discussions + provider._get_own_user_id = MagicMock(return_value=BOT_ID) + provider.gl = MagicMock() + provider.gl.projects.get.return_value.repository_compare.return_value = {"diffs": diffs} + return provider + + +@pytest.fixture(autouse=True) +def flag(): + snapshot = snapshot_settings(["gitlab.auto_resolve_fixed_inline_threads"]) + get_settings().set("gitlab.auto_resolve_fixed_inline_threads", True) + yield + restore_settings(snapshot) + + +def test_removed_lines_from_patch() -> None: + assert _removed_lines_from_patch(PATCH) == {2} + assert _removed_lines_from_patch(INSERT_ONLY_PATCH) == set() + + +def test_no_newline_marker_is_not_a_file_line() -> None: + # "\ No newline at end of file" must not advance the base line counter. + assert _removed_lines_from_patch(NO_NEWLINE_PATCH) == {2} + + +def test_double_dash_content_line_is_a_removal() -> None: + # A removed source line "--flag" is encoded as "---flag" inside the hunk. + assert _removed_lines_from_patch(DOUBLE_DASH_PATCH) == {2} + + +def test_thread_on_removed_line_resolves() -> None: + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + assert provider.reconcile_code_suggestion_threads() == 1 + assert discussion.resolved is True + discussion.save.assert_called_once() + + +def test_compare_uses_straight_diff() -> None: + # The default merge-base comparison would report lines dropped by a rebase + # as removed; the sweep must compare the recorded head to the current head + # directly. + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.gl.projects.get.return_value.repository_compare.assert_called_once_with( + OLD_SHA, HEAD_SHA, straight=True) + + +def test_thread_on_untouched_line_stays_open() -> None: + discussion = _discussion(line=3) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + assert discussion.resolved is False + discussion.save.assert_not_called() + + +def test_insertion_at_flagged_position_does_not_resolve() -> None: + # A line inserted where the comment used to point must not resolve the + # thread: coordinates belong to the comment's head, not the current one. + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": INSERT_ONLY_PATCH}]) + provider.reconcile_code_suggestion_threads() + assert discussion.resolved is False + + +def test_thread_on_current_head_is_not_compared() -> None: + discussion = _discussion(line=2, head_sha=HEAD_SHA) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.gl.projects.get.return_value.repository_compare.assert_not_called() + assert discussion.resolved is False + + +def test_ineligible_threads_skip_compare() -> None: + # Human and already-resolved threads must not cost a repository_compare call. + foreign = _discussion(line=2, body="a human comment") + other_author = _discussion(line=2, author_id=999) + resolved_thread = _discussion(line=2, resolved=True) + provider = _provider([foreign, other_author, resolved_thread], + [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.gl.projects.get.return_value.repository_compare.assert_not_called() + for d in (foreign, other_author, resolved_thread): + d.save.assert_not_called() + + +def test_object_shaped_compare_response_is_handled() -> None: + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.gl.projects.get.return_value.repository_compare.return_value = SimpleNamespace( + diffs=[SimpleNamespace(new_path="app.py", old_path="app.py", diff=PATCH)]) + provider.reconcile_code_suggestion_threads() + assert discussion.resolved is True + + +def test_disabled_flag_is_noop() -> None: + get_settings().set("gitlab.auto_resolve_fixed_inline_threads", False) + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.mr.discussions.list.assert_not_called() + assert discussion.resolved is False + + +def test_non_agent_and_other_author_threads_stay_open() -> None: + foreign = _discussion(line=2, body="a human comment") + other_author = _discussion(line=2, author_id=999) + provider = _provider([foreign, other_author], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + assert foreign.resolved is False + assert other_author.resolved is False + + +def test_thread_eligibility_guards() -> None: + assert _eligible_own_inline_thread(_discussion(resolved=True), BOT_ID) is None + assert _eligible_own_inline_thread(_discussion(body="human"), BOT_ID) is None + position = _eligible_own_inline_thread(_discussion(line=2), BOT_ID) + assert position is not None + assert _flagged_line_removed(position, {"app.py": {2}}) + assert not _flagged_line_removed(position, {"app.py": {3}}) + + +def test_deletion_anchored_thread_stays_open() -> None: + # old_line is a coordinate in the MR base, not the comment head: a head-side + # removal at the same number must not resolve a deletion-anchored thread. + position = {"position_type": "text", "new_path": "app.py", "old_path": "app.py", + "old_line": 2, "head_sha": OLD_SHA} + assert not _flagged_line_removed(position, {"app.py": {2}}) + + +def test_repeat_sweep_costs_no_api_calls() -> None: + # PRCodeSuggestions.run() and publish_code_suggestions share one provider; + # the second sweep must not list discussions again. + discussion = _discussion(line=3) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.reconcile_code_suggestion_threads() + provider.mr.discussions.list.assert_called_once() + + +def test_supports_code_suggestion_state() -> None: + # PRCodeSuggestions.run() calls reconcile_code_suggestion_threads through + # the generic provider contract when this reports support. + provider = _provider([], []) + assert provider.supports_code_suggestion_state() is True + assert provider.reconcile_code_suggestion_threads() == 0 From 9324f7dde291c72699d4f11c816307d5719fcfe9 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 18 Sep 2026 13:05:21 -0500 Subject: [PATCH 164/238] fix(github): surface incomplete file-set errors (#3488) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- docs/docs/installation/github.md | 6 + pr_agent/agent/pr_agent.py | 71 +++++++- pr_agent/servers/github_action_runner.py | 24 ++- tests/unittest/test_comment_reactions.py | 38 ++++ .../test_github_action_runner_core.py | 61 +++++++ tests/unittest/test_pr_agent_routing.py | 162 ++++++++++++++++++ tests/unittest/test_telemetry_privacy.py | 63 +++++++ 7 files changed, 417 insertions(+), 8 deletions(-) diff --git a/docs/docs/installation/github.md b/docs/docs/installation/github.md index ef708a040e..98f3da1f26 100644 --- a/docs/docs/installation/github.md +++ b/docs/docs/installation/github.md @@ -558,6 +558,12 @@ If you encounter rate limiting: ``` See the [Restricted Mode guide](../usage-guide/additional_configurations.md#restricted-mode) for details. +**Error: "PR-Agent command was not run" for incomplete GitHub files** +- **Cause**: GitHub limits pull-request changed-file responses to 3,000 files. A mismatch can also occur when GitHub + returns inconsistent file-count metadata. +- **Solution**: If the pull request changes more than 3,000 files, split it into smaller pull requests and run the + command again. Otherwise, retry the command. + **Error: "Invalid JSON format"** - **Solution**: Check that arrays are properly formatted as JSON strings: diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index 07e68bdd42..7a3255b353 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -11,8 +11,10 @@ from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.cli_args import CliArgs -from pr_agent.algo.utils import update_settings_from_args +from pr_agent.algo.utils import add_comment_identity, comment_matches_identity, update_settings_from_args from pr_agent.config_loader import get_settings, global_settings +from pr_agent.git_providers import get_git_provider_with_context +from pr_agent.git_providers.github_provider import IncompletePullRequestFilesError from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.log import get_logger from pr_agent.telemetry.meter import get_commands_counter @@ -56,6 +58,71 @@ commands = list(command2class.keys()) +INCOMPLETE_GITHUB_FILES_COMMENT_MARKER = "" +INCOMPLETE_GITHUB_FILES_COMMENT = ( + "## PR-Agent command was not run\n\n" + "GitHub returned an incomplete or inconsistent changed-file set for this pull request, so PR-Agent stopped " + "instead of analyzing only part of it.\n\n" + "GitHub limits changed-file responses to 3,000 files. If this pull request changes more than 3,000 files, " + "split it into smaller pull requests and run the command again. Otherwise, retry the command." +) + + +def publish_incomplete_github_files_comment(pr_url: str) -> None: + """Publish one trusted, sanitized PR-level notice without replacing the primary failure.""" + try: + _publish_incomplete_github_files_comment(pr_url) + except Exception: + # Preserve the original completeness failure by containing every + # ordinary provider or rendering failure from this secondary notice. + get_logger().exception("Failed to prepare the incomplete-files notice") + + +def _publish_incomplete_github_files_comment(pr_url: str) -> None: + if not get_settings().get("CONFIG.PUBLISH_OUTPUT", True): + return + + try: + provider = get_git_provider_with_context(pr_url) + except Exception: + get_logger().exception("Failed to get a GitHub provider for the incomplete-files notice") + return + + try: + comments = provider.get_issue_comments_newest_first() + except Exception: + get_logger().exception("Failed to inspect existing incomplete-files notices") + comments = [] + + for comment in comments: + try: + body = provider._get_comment_body(comment) + except Exception: + # Ignore comments whose bodies cannot be read. Continue looking + # for a verifiable PR-Agent marker and publish if none can be + # confirmed. + get_logger().warning( + "Failed to read an existing incomplete-files notice; continuing" + ) + continue + if not comment_matches_identity(body, INCOMPLETE_GITHUB_FILES_COMMENT_MARKER): + continue + try: + if provider.is_comment_authored_by_pr_agent(comment): + return + except Exception: + get_logger().exception("Failed to verify the author of an incomplete-files notice") + + body = add_comment_identity( + INCOMPLETE_GITHUB_FILES_COMMENT, + INCOMPLETE_GITHUB_FILES_COMMENT_MARKER, + provider, + ) + try: + provider.publish_comment(body) + except Exception: + get_logger().exception("Failed to publish the incomplete-files notice") + def _split_command(command: str) -> list[tuple[str, bool]]: """Split an auto command and retain whether each token was quoted. @@ -213,6 +280,8 @@ async def _handle_request( ) except Exception as e: get_logger().exception("Failed to process the command.") + if isinstance(e, IncompletePullRequestFilesError): + publish_incomplete_github_files_comment(pr_url) # Status carries no description: it is free text, and the exception # message can embed PR URLs, repo names, or other request content. span.set_status(StatusCode.ERROR) diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index 4a3b870855..81402ab805 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -5,7 +5,7 @@ import dynaconf -from pr_agent.agent.pr_agent import PRAgent +from pr_agent.agent.pr_agent import PRAgent, publish_incomplete_github_files_comment from pr_agent.algo.ai_handlers.litellm_helpers import ( DEFAULT_CALLBACK_TIMEOUT_SECONDS, drain_litellm_callbacks, @@ -14,6 +14,7 @@ from pr_agent.algo.artifacts import inject_artifact_context as _inject_artifact_context from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider +from pr_agent.git_providers.github_provider import IncompletePullRequestFilesError from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.log import get_logger from pr_agent.servers.github_app import handle_line_comments, matches_review_state @@ -57,6 +58,15 @@ def get_list_setting_or_env(key, fallback=None): return [value] +async def _run_auto_tool(tool_class, pr_url): + """Run a direct auto tool while preserving GitHub Action failure semantics.""" + try: + await tool_class(pr_url).run() + except IncompletePullRequestFilesError: + publish_incomplete_github_files_comment(pr_url) + raise + + async def _run_review_commands(event_payload): action = event_payload.get("action") if action != "submitted": @@ -286,11 +296,11 @@ async def run_action(): # invoke by default all three tools if auto_describe is None or is_true(auto_describe): - await PRDescription(pr_url).run() + await _run_auto_tool(PRDescription, pr_url) if auto_review is None or is_true(auto_review): - await PRReviewer(pr_url).run() + await _run_auto_tool(PRReviewer, pr_url) if auto_improve is None or is_true(auto_improve): - await PRCodeSuggestions(pr_url).run() + await _run_auto_tool(PRCodeSuggestions, pr_url) else: get_logger().info(f"Skipping action: {action}") @@ -401,11 +411,11 @@ async def run_action(): ) if auto_describe is None or is_true(auto_describe): - await PRDescription(pr_url).run() + await _run_auto_tool(PRDescription, pr_url) if auto_review is None or is_true(auto_review): - await PRReviewer(pr_url).run() + await _run_auto_tool(PRReviewer, pr_url) if auto_improve is None or is_true(auto_improve): - await PRCodeSuggestions(pr_url).run() + await _run_auto_tool(PRCodeSuggestions, pr_url) def _inject_ci_conclusion(conclusion): diff --git a/tests/unittest/test_comment_reactions.py b/tests/unittest/test_comment_reactions.py index ee0852f35d..f39fbb96b2 100644 --- a/tests/unittest/test_comment_reactions.py +++ b/tests/unittest/test_comment_reactions.py @@ -400,6 +400,44 @@ async def run(self): assert get_settings().config.get("propagate_tool_errors") is False +@pytest.mark.asyncio +async def test_incomplete_files_constructor_failure_is_visible_and_marked_failed( + reactions, comment_handler, monkeypatch +): + import pr_agent.agent.pr_agent as pr_agent_module + + reactions(failure="confused") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + class IncompleteReviewTool: + def __init__(self, _pr_url, ai_handler=None, args=None): + raise pr_agent_module.IncompletePullRequestFilesError("private mismatch details") + + monkeypatch.setitem(pr_agent_module.command2class, "review", IncompleteReviewTool) + + github_app, provider = comment_handler + provider.publish_comment = MagicMock() + monkeypatch.setattr( + pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider + ) + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + result = await github_app.handle_comments_on_pr( + _comment_event(), "issue_comment", "user", "1", "created", {}, agent + ) + + assert result is False + published = provider.publish_comment.call_args.args[0] + assert "GitHub returned an incomplete or inconsistent changed-file set" in published + assert "If this pull request changes more than 3,000 files" in published + assert "Otherwise, retry the command" in published + assert "private mismatch details" not in published + assert provider.reactions == [(4242, "eyes"), (4242, "confused")] + + @pytest.mark.asyncio @pytest.mark.parametrize("command", ["add_docs", "generate_labels"]) async def test_documentation_and_label_failures_get_failure_outcomes( diff --git a/tests/unittest/test_github_action_runner_core.py b/tests/unittest/test_github_action_runner_core.py index 489ae91a95..638736d35a 100644 --- a/tests/unittest/test_github_action_runner_core.py +++ b/tests/unittest/test_github_action_runner_core.py @@ -1,10 +1,13 @@ import copy import json +from unittest.mock import Mock import pytest +import pr_agent.agent.pr_agent as pr_agent_module import pr_agent.servers.github_action_runner as github_action_runner from pr_agent.config_loader import get_settings +from pr_agent.git_providers.github_provider import IncompletePullRequestFilesError def test_is_true_accepts_bool_and_case_insensitive_true_string(): @@ -24,6 +27,64 @@ async def test_run_action_returns_when_required_env_is_missing(monkeypatch, caps assert "GITHUB_EVENT_NAME not set" in capsys.readouterr().out +@pytest.mark.asyncio +async def test_direct_auto_tool_notifies_and_reraises_incomplete_constructor_error(monkeypatch): + error = IncompletePullRequestFilesError("private repository details") + notify = Mock() + + class IncompleteTool: + def __init__(self, _pr_url): + raise error + + monkeypatch.setattr(github_action_runner, "publish_incomplete_github_files_comment", notify) + + with pytest.raises(IncompletePullRequestFilesError) as raised: + await github_action_runner._run_auto_tool(IncompleteTool, "https://example/pr/1") + + assert raised.value is error + notify.assert_called_once_with("https://example/pr/1") + + +@pytest.mark.asyncio +async def test_direct_auto_tool_leaves_unexpected_constructor_error_unchanged(monkeypatch): + error = RuntimeError("unrelated") + notify = Mock() + + class BrokenTool: + def __init__(self, _pr_url): + raise error + + monkeypatch.setattr(github_action_runner, "publish_incomplete_github_files_comment", notify) + + with pytest.raises(RuntimeError) as raised: + await github_action_runner._run_auto_tool(BrokenTool, "https://example/pr/1") + + assert raised.value is error + notify.assert_not_called() + + +@pytest.mark.asyncio +async def test_direct_auto_tool_publishes_and_preserves_error_when_comment_decode_fails( + monkeypatch +): + error = IncompletePullRequestFilesError("private repository details") + provider = Mock() + provider.get_issue_comments_newest_first.return_value = [object()] + provider._get_comment_body.side_effect = RuntimeError("comment decoding failed") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _url: provider) + + class IncompleteTool: + def __init__(self, _pr_url): + raise error + + with pytest.raises(IncompletePullRequestFilesError) as raised: + await github_action_runner._run_auto_tool(IncompleteTool, "https://example/pr/1") + + assert raised.value is error + provider.publish_comment.assert_called_once() + + @pytest.mark.asyncio async def test_run_action_invokes_enabled_auto_tools_for_pull_request_event(monkeypatch, tmp_path): settings = get_settings() diff --git a/tests/unittest/test_pr_agent_routing.py b/tests/unittest/test_pr_agent_routing.py index b3be0f44a1..1b6df8a969 100644 --- a/tests/unittest/test_pr_agent_routing.py +++ b/tests/unittest/test_pr_agent_routing.py @@ -31,6 +31,15 @@ def _patch_request_dependencies(monkeypatch, validate_result=(True, None), updat monkeypatch.setattr(pr_agent_module, "update_settings_from_args", update_settings_fn) +def _incomplete_files_provider(comments=()): + provider = Mock() + provider.get_issue_comments_newest_first.return_value = list(comments) + provider._get_comment_body.side_effect = lambda comment: comment.get("body", "") + provider.is_comment_authored_by_pr_agent.return_value = False + provider.supports_html_comment_markers.return_value = True + return provider + + @pytest.mark.asyncio async def test_handle_request_routes_known_command_and_notifies(monkeypatch): runs = [] @@ -181,6 +190,159 @@ async def run(self): assert handled is False +@pytest.mark.asyncio +async def test_incomplete_github_files_constructor_error_publishes_sanitized_notice(monkeypatch): + secret = "private/repo mismatch: expected 5001 files but fetched 3000" + provider = _incomplete_files_provider() + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError(secret) + + _patch_request_dependencies(monkeypatch) + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "custom", IncompleteTool) + + handled = await pr_agent_module.PRAgent()._handle_request( + "https://example/pr/1", "/custom" + ) + + assert handled is False + provider.publish_comment.assert_called_once() + published = provider.publish_comment.call_args.args[0] + assert "GitHub returned an incomplete or inconsistent changed-file set" in published + assert "If this pull request changes more than 3,000 files" in published + assert "Otherwise, retry the command" in published + assert "command was not run" in published + assert secret not in published + assert pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER in published.splitlines()[:5] + + +@pytest.mark.asyncio +async def test_unexpected_constructor_error_does_not_publish_incomplete_files_notice(monkeypatch): + provider_factory = Mock() + + class BrokenTool: + def __init__(self, pr_url, ai_handler, args): + raise RuntimeError("unrelated") + + _patch_request_dependencies(monkeypatch) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", provider_factory) + monkeypatch.setitem(pr_agent_module.command2class, "custom", BrokenTool) + + handled = await pr_agent_module.PRAgent()._handle_request( + "https://example/pr/1", "/custom" + ) + + assert handled is False + provider_factory.assert_not_called() + + +def test_incomplete_files_notice_deduplicates_trusted_agent_comment(monkeypatch): + existing = { + "body": ( + "## Existing notice\n\n" + f"{pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER}\n\nDetails" + ) + } + provider = _incomplete_files_provider([existing]) + provider.is_comment_authored_by_pr_agent.return_value = True + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_not_called() + + +def test_foreign_incomplete_files_marker_does_not_suppress_notice(monkeypatch): + existing = { + "body": ( + "## Spoofed notice\n\n" + f"{pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER}\n\nDetails" + ) + } + provider = _incomplete_files_provider([existing]) + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_fails_open_when_author_cannot_be_verified(monkeypatch): + existing = { + "body": ( + "## Existing notice\n\n" + f"{pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER}\n\nDetails" + ) + } + provider = _incomplete_files_provider([existing]) + provider.is_comment_authored_by_pr_agent.side_effect = RuntimeError("identity unavailable") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_fails_open_when_comment_lookup_fails(monkeypatch): + provider = _incomplete_files_provider() + provider.get_issue_comments_newest_first.side_effect = RuntimeError("lookup unavailable") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_fails_open_when_comment_body_cannot_be_read(monkeypatch): + provider = _incomplete_files_provider([object()]) + provider._get_comment_body.side_effect = RuntimeError("comment decoding failed") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +@pytest.mark.asyncio +async def test_incomplete_files_notice_publication_failure_keeps_command_failed(monkeypatch): + provider = _incomplete_files_provider() + provider.publish_comment.side_effect = RuntimeError("publication unavailable") + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError("internal details") + + _patch_request_dependencies(monkeypatch) + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "custom", IncompleteTool) + + handled = await pr_agent_module.PRAgent()._handle_request( + "https://example/pr/1", "/custom" + ) + + assert handled is False + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_respects_disabled_output(monkeypatch): + provider_factory = Mock() + monkeypatch.setattr(get_settings().config, "publish_output", False, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", provider_factory) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider_factory.assert_not_called() + + @pytest.mark.asyncio async def test_handle_request_wrapper_returns_false_on_exception(monkeypatch): async def raise_error(self, pr_url, request, notify=None): diff --git a/tests/unittest/test_telemetry_privacy.py b/tests/unittest/test_telemetry_privacy.py index b080f7151c..6b33ed2ee1 100644 --- a/tests/unittest/test_telemetry_privacy.py +++ b/tests/unittest/test_telemetry_privacy.py @@ -10,6 +10,8 @@ inflate the counter. """ +from unittest.mock import Mock + import pytest from opentelemetry.trace import StatusCode @@ -196,6 +198,67 @@ async def test_tool_exception_not_auto_recorded_by_default(telemetry, monkeypatc assert SENTINEL_URL not in _all_exported_text(exporter) +@pytest.mark.asyncio +async def test_incomplete_files_error_keeps_bounded_telemetry_when_notification_fails( + telemetry, monkeypatch +): + exporter, _ = telemetry + provider = Mock() + provider.get_issue_comments_newest_first.return_value = [] + provider.supports_html_comment_markers.return_value = True + provider.publish_comment.side_effect = RuntimeError("notification failed") + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError( + f"failed to fetch {SENTINEL_URL}" + ) + + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "customcmd", IncompleteTool) + + handled = await PRAgent(ai_handler="fake")._handle_request( + "https://example/pr/1", ["customcmd"] + ) + + assert handled is False + provider.publish_comment.assert_called_once() + span = exporter.get_finished_spans()[0] + assert dict(span.attributes)["error.type"] == "IncompletePullRequestFilesError" + assert SENTINEL_URL not in _all_exported_text(exporter) + + +@pytest.mark.asyncio +async def test_incomplete_files_error_keeps_primary_telemetry_when_comment_decode_fails( + telemetry, monkeypatch +): + exporter, _ = telemetry + provider = Mock() + provider.get_issue_comments_newest_first.return_value = [object()] + provider._get_comment_body.side_effect = RuntimeError( + f"could not decode comment from {SENTINEL_URL}" + ) + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError("primary failure") + + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "customcmd", IncompleteTool) + + handled = await PRAgent(ai_handler="fake")._handle_request( + "https://example/pr/1", ["customcmd"] + ) + + assert handled is False + provider.publish_comment.assert_called_once() + span = exporter.get_finished_spans()[0] + assert dict(span.attributes)["error.type"] == "IncompletePullRequestFilesError" + assert SENTINEL_URL not in _all_exported_text(exporter) + + @pytest.mark.asyncio async def test_propagating_exception_recorded_when_opted_in(telemetry, monkeypatch): exporter, _ = telemetry From 8147e55865687f8c125266465e9319adece36dc0 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:05:37 +0800 Subject: [PATCH 165/238] fix: redact AWS credential discovery errors from logs (#3487) --- .../algo/ai_handlers/litellm_ai_handler.py | 10 ++- .../test_litellm_aws_error_logging.py | 84 +++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tests/unittest/test_litellm_aws_error_logging.py diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 040eb56d4f..7c96e5c810 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -2641,7 +2641,7 @@ def _initialize_aws_imds_credentials(self) -> bool: try: region = session.region_name except Exception as e: - get_logger().warning(f"AWS_USE_IMDS: failed to resolve region via boto3: {e}") + get_logger().warning(f"AWS_USE_IMDS: failed to resolve region via boto3: {type(e).__name__}") creds = session.get_credentials() if creds: frozen_credentials = creds.get_frozen_credentials() @@ -2654,9 +2654,11 @@ def _initialize_aws_imds_credentials(self) -> bool: get_logger().warning( "AWS_USE_IMDS is set but boto3 found no credentials; falling through to static keys" ) - except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError, OSError): - get_logger().exception( - "AWS_USE_IMDS: failed to resolve credentials via boto3; falling through to static keys" + except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError, OSError) as e: + # Keep provider error text and traceback locals out of credential-resolution logs. + get_logger().error( + "AWS_USE_IMDS: failed to resolve credentials via boto3; falling through to static keys: " + f"{type(e).__name__}" ) if not region: diff --git a/tests/unittest/test_litellm_aws_error_logging.py b/tests/unittest/test_litellm_aws_error_logging.py new file mode 100644 index 0000000000..40a2d50ad2 --- /dev/null +++ b/tests/unittest/test_litellm_aws_error_logging.py @@ -0,0 +1,84 @@ +"""Cover AWS credential and region discovery log regressions.""" + +from unittest.mock import PropertyMock + +import pytest +from botocore.exceptions import ClientError, CredentialRetrievalError + +import pr_agent.algo.ai_handlers.litellm_ai_handler as litellm_handler +from pr_agent.log import get_logger +from tests.unittest import test_litellm_imds as imds_tests + +isolate_aws = imds_tests.isolate_aws +aws_session = imds_tests.aws_session + + +@pytest.fixture(params=[False, True], ids=["console", "json"]) +def aws_log_messages(request): + messages = [] + sink = get_logger().add( + messages.append, + filter=lambda record: record["name"] == litellm_handler.__name__, + format="{message}", + serialize=request.param, + backtrace=True, + diagnose=True, + ) + try: + yield messages + finally: + get_logger().remove(sink) + + +def _assert_safe_aws_error_log(messages, expected_message, level): + assert messages + assert "aws-error-secret-sentinel" not in "".join(messages) + assert "Traceback (most recent call last)" not in "".join(messages) + assert all(message.record["exception"] is None for message in messages) + assert any( + message.record["message"] == expected_message and message.record["level"].name == level + for message in messages + ) + + +@pytest.mark.parametrize("error", [ + CredentialRetrievalError(provider="imds", error_msg="aws-error-secret-sentinel"), + ClientError( + {"Error": {"Code": "AccessDenied", "Message": "aws-error-secret-sentinel"}}, "AssumeRole", + ), + OSError("aws-error-secret-sentinel"), +]) +@pytest.mark.parametrize("static_fallback", [False, True]) +def test_aws_credential_discovery_errors_do_not_log_details( + monkeypatch, aws_session, aws_log_messages, error, static_fallback, +): + monkeypatch.setenv("AWS_USE_IMDS", "true") + if static_fallback: + monkeypatch.setattr(litellm_handler, "get_settings", imds_tests._static_aws_settings) + aws_session.get_credentials.side_effect = error + + handler = litellm_handler.LiteLLMAIHandler() + + assert not handler._aws_imds_mode + assert handler._aws_imds_fell_back is static_fallback + assert handler._aws_active_creds == (handler._aws_static_creds if static_fallback else {}) + _assert_safe_aws_error_log( + aws_log_messages, + "AWS_USE_IMDS: failed to resolve credentials via boto3; falling through to static keys: " + f"{type(error).__name__}", + "ERROR", + ) + + +def test_aws_region_error_does_not_log_details(monkeypatch, aws_session, aws_log_messages): + monkeypatch.setenv("AWS_USE_IMDS", "true") + type(aws_session).region_name = PropertyMock(side_effect=ValueError("aws-error-secret-sentinel")) + + handler = litellm_handler.LiteLLMAIHandler() + + assert handler._aws_imds_mode + assert handler._aws_active_creds["aws_access_key_id"] == "IMDS-KEY" + assert "aws_region_name" not in handler._aws_active_creds + _assert_safe_aws_error_log( + aws_log_messages, "AWS_USE_IMDS: failed to resolve region via boto3: ValueError", "WARNING", + ) From b005939ee191c7664007ac40ec0f74eb2467ddf5 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 18 Sep 2026 14:57:59 -0500 Subject: [PATCH 166/238] fix(bitbucket): split raw diffs at file headers (#3492) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/git_providers/bitbucket_provider.py | 6 +- tests/unittest/test_bitbucket_provider.py | 74 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index 2ed38b8d70..01d4082d12 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -26,6 +26,10 @@ def _gef_filename(diff): return diff.old.path +def _split_raw_diff(raw_diff: str) -> list[str]: + return [part for part in re.split(r"(?m)(?=^diff --git )", raw_diff) if part.startswith("diff --git ")] + + class BitbucketProvider(GitProvider): def __init__( self, pr_url: Optional[str] = None, incremental: Optional[bool] = False @@ -287,7 +291,7 @@ def get_diff_files(self) -> list[FilePatchInfo]: if pr_patches is None: raise ValueError(f"Failed to decode PR patch with encodings {encodings_to_try}") - diff_split = ["diff --git" + x for x in pr_patches.split("diff --git") if x.strip()] + diff_split = _split_raw_diff(pr_patches) # filter all elements of 'diff_split' that are of indices in 'diffs_original' that are not in 'diffs' if len(diff_split) > len(diffs) and len(diffs_original) == len(diff_split): diff_split = [diff_split[i] for i in range(len(diff_split)) if diffs_original[i] in diffs] diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index db4058eea2..29fcdbc4b2 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -281,6 +281,80 @@ def test_get_diff_files_warns_for_nonempty_change_without_hunks(self): "Bitbucket failed to get diff for file src/example.py" ) + def test_get_diff_files_keeps_diff_header_literals_inside_hunks(self): + raw_diff = """diff --git a/src/parser.py b/src/parser.py +index 1111111..2222222 100644 +--- a/src/parser.py ++++ b/src/parser.py +@@ -1,2 +1,2 @@ + diff --git context literal +-diff --git removed literal ++diff --git added literal +""" + + diff_file = self._get_single_diff_file( + raw_diff, + "modified", + 1, + 1, + "src/parser.py", + ) + + assert diff_file.patch == ( + "@@ -1,2 +1,2 @@\n" + " diff --git context literal\n" + "-diff --git removed literal\n" + "+diff --git added literal\n" + ) + + def test_get_diff_files_splits_crlf_headers_after_preamble_in_diffstat_order(self): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.diff_files = None + provider.pr = MagicMock() + + first = MagicMock() + first.new.path = "src/first.py" + first.old.path = "src/first.py" + first.data = {"status": "modified", "lines_added": 1, "lines_removed": 1} + + second = MagicMock() + second.new.path = "src/second.py" + second.old.path = "src/second.py" + second.data = {"status": "modified", "lines_added": 1, "lines_removed": 1} + + diffstats = [first, second] + provider.pr.diffstat.return_value = diffstats + provider.pr.diff.return_value = ( + "Bitbucket generated diff\r\n" + "\r\n" + "diff --git a/src/first.py b/src/first.py\r\n" + "index 1111111..2222222 100644\r\n" + "--- a/src/first.py\r\n" + "+++ b/src/first.py\r\n" + "@@ -1 +1 @@\r\n" + "-old first\r\n" + "+new first\r\n" + "diff --git a/src/second.py b/src/second.py\r\n" + "index 3333333..4444444 100644\r\n" + "--- a/src/second.py\r\n" + "+++ b/src/second.py\r\n" + "@@ -1 +1 @@\r\n" + "-old second\r\n" + "+new second\r\n" + ) + + settings = MagicMock() + settings.get.return_value = True + with ( + patch("pr_agent.git_providers.bitbucket_provider.filter_ignored", return_value=diffstats), + patch("pr_agent.git_providers.bitbucket_provider.get_settings", return_value=settings), + ): + diff_files = provider.get_diff_files() + + assert [diff_file.filename for diff_file in diff_files] == ["src/first.py", "src/second.py"] + assert diff_files[0].patch.startswith("@@ -1 +1 @@\r\n-old first") + assert diff_files[1].patch.startswith("@@ -1 +1 @@\r\n-old second") + def test_get_repo_file_content_reads_from_target_branch(self): # Repo-context files must be read from the PR destination (target) branch, # matching the other providers. From 012f0ad60bf12942c94aa9a8cc30ada13f486fb7 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 18 Sep 2026 14:58:16 -0500 Subject: [PATCH 167/238] fix(codecommit): use merge base for analysis (#3493) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/git_providers/codecommit_client.py | 1 + pr_agent/git_providers/codecommit_provider.py | 10 +- tests/unittest/test_codecommit_client.py | 13 ++- tests/unittest/test_codecommit_provider.py | 104 +++++++++++++++--- 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/pr_agent/git_providers/codecommit_client.py b/pr_agent/git_providers/codecommit_client.py index f134acfcb8..6de676c302 100644 --- a/pr_agent/git_providers/codecommit_client.py +++ b/pr_agent/git_providers/codecommit_client.py @@ -45,6 +45,7 @@ def __init__(self, json: dict): self.source_branch = json.get("sourceReference", "") self.destination_commit = json.get("destinationCommit", "") self.destination_branch = json.get("destinationReference", "") + self.merge_base = json.get("mergeBase", "") class CodeCommitClient: diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index c2878d4789..a8ccb6d6e8 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -53,6 +53,7 @@ def __init__( repository_name: Optional[str] = None, source_commit: Optional[str] = None, destination_commit: Optional[str] = None, + comparison_base_commit: Optional[str] = None, ): self.a_path = a_path self.a_blob_id = a_blob_id @@ -63,6 +64,7 @@ def __init__( self.repository_name = repository_name self.source_commit = source_commit self.destination_commit = destination_commit + self.comparison_base_commit = comparison_base_commit class CodeCommitProvider(GitProvider): @@ -117,7 +119,7 @@ def get_files(self) -> list[CodeCommitFile]: self.git_files = [] for target in self._get_target_contexts(): differences = self.codecommit_client.get_differences( - target["repository_name"], target["destination_commit"], target["source_commit"] + target["repository_name"], target["comparison_base_commit"], target["source_commit"] ) for item in differences: self.git_files.append( @@ -130,6 +132,7 @@ def get_files(self) -> list[CodeCommitFile]: repository_name=target["repository_name"], source_commit=target["source_commit"], destination_commit=target["destination_commit"], + comparison_base_commit=target["comparison_base_commit"], ) ) return self.git_files @@ -159,11 +162,12 @@ def get_diff_files(self) -> list[FilePatchInfo]: repository_name = diff_item.repository_name or self.repo_name destination_commit = diff_item.destination_commit or self.pr.destination_commit source_commit = diff_item.source_commit or self.pr.source_commit + comparison_base_commit = diff_item.comparison_base_commit or destination_commit try: if diff_item.a_blob_id: patch_filename = diff_item.a_path original_file_content_str = self.codecommit_client.get_file( - repository_name, diff_item.a_path, destination_commit) + repository_name, diff_item.a_path, comparison_base_commit) if isinstance(original_file_content_str, (bytes, bytearray)): original_file_content_str = original_file_content_str.decode("utf-8") else: @@ -544,6 +548,7 @@ def _get_target_contexts(self): "repository_name": self.repo_name, "source_commit": self.pr.source_commit, "destination_commit": self.pr.destination_commit, + "comparison_base_commit": self.pr.destination_commit, }] return [ @@ -551,6 +556,7 @@ def _get_target_contexts(self): "repository_name": getattr(target, "repository_name", "") or self.repo_name, "source_commit": target.source_commit, "destination_commit": target.destination_commit, + "comparison_base_commit": getattr(target, "merge_base", "") or target.destination_commit, } for target in targets ] diff --git a/tests/unittest/test_codecommit_client.py b/tests/unittest/test_codecommit_client.py index 3380b96e8d..6a19d49a7f 100644 --- a/tests/unittest/test_codecommit_client.py +++ b/tests/unittest/test_codecommit_client.py @@ -120,6 +120,7 @@ def test_get_pr(self): "sourceReference": "branch1", "destinationCommit": "commit2", "destinationReference": "branch2", + "mergeBase": "merge-base", "repositoryName": "my_test_repo", } ], @@ -136,6 +137,7 @@ def test_get_pr(self): assert pr.targets[0].source_branch == "branch1" assert pr.targets[0].destination_commit == "commit2" assert pr.targets[0].destination_branch == "branch2" + assert pr.targets[0].merge_base == "merge-base" def test_get_pr_preserves_all_target_repositories(self): api = CodeCommitClient() @@ -148,11 +150,13 @@ def test_get_pr_preserves_all_target_repositories(self): "repositoryName": "repo-one", "sourceCommit": "source-one", "destinationCommit": "destination-one", + "mergeBase": "base-one", }, { "repositoryName": "repo-two", "sourceCommit": "source-two", "destinationCommit": "destination-two", + "mergeBase": "base-two", }, ], } @@ -160,9 +164,12 @@ def test_get_pr_preserves_all_target_repositories(self): pr = api.get_pr("repo-one", 321) - assert [(target.repository_name, target.source_commit, target.destination_commit) for target in pr.targets] == [ - ("repo-one", "source-one", "destination-one"), - ("repo-two", "source-two", "destination-two"), + assert [ + (target.repository_name, target.source_commit, target.destination_commit, target.merge_base) + for target in pr.targets + ] == [ + ("repo-one", "source-one", "destination-one", "base-one"), + ("repo-two", "source-two", "destination-two", "base-two"), ] def test_get_comments_for_pull_request_uses_safe_page_size_and_all_pages(self): diff --git a/tests/unittest/test_codecommit_provider.py b/tests/unittest/test_codecommit_provider.py index 117cbe5b93..e1c0a35d84 100644 --- a/tests/unittest/test_codecommit_provider.py +++ b/tests/unittest/test_codecommit_provider.py @@ -19,8 +19,16 @@ def test_valid_parameters(self): b_path = "path/to/file_b" b_blob_id = "67890" edit_type = EDIT_TYPE.ADDED - - file = CodeCommitFile(a_path, a_blob_id, b_path, b_blob_id, edit_type) + comparison_base_commit = "merge-base" + + file = CodeCommitFile( + a_path, + a_blob_id, + b_path, + b_blob_id, + edit_type, + comparison_base_commit=comparison_base_commit, + ) assert file.a_path == a_path assert file.a_blob_id == a_blob_id @@ -28,6 +36,7 @@ def test_valid_parameters(self): assert file.b_blob_id == b_blob_id assert file.edit_type == edit_type assert file.filename == b_path + assert file.comparison_base_commit == comparison_base_commit class TestCodeCommitProvider: @@ -62,6 +71,7 @@ def _make_persistent_provider(targets=None): source_branch="refs/heads/feature", destination_commit="destination-commit-1", destination_branch="refs/heads/main", + merge_base="merge-base-1", ) ] provider.pr = PullRequestCCMimic("Persistent PR", [], targets=targets) @@ -221,6 +231,7 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): source_branch="feature/one", destination_commit="destination-commit-1", destination_branch="main", + merge_base="merge-base-1", ) second_target = MagicMock( repository_name="destination-repository", @@ -228,18 +239,50 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): source_branch="feature/two", destination_commit="destination-commit-2", destination_branch="release", + merge_base="merge-base-2", ) provider.codecommit_client.get_pr.return_value = MagicMock( title="Multi-target PR", description="Review both targets", targets=[first_target, second_target], ) - provider.codecommit_client.get_differences.side_effect = [ - [MagicMock(before_blob_path="one.py", before_blob_id="before-1", - after_blob_path="one.py", after_blob_id="after-1", change_type="M")], - [MagicMock(before_blob_path="two.py", before_blob_id="before-2", - after_blob_path="two.py", after_blob_id="after-2", change_type="M")], - ] + differences_by_repository = { + "source-repository": [ + MagicMock( + before_blob_path="one.py", + before_blob_id="before-1", + after_blob_path="one.py", + after_blob_id="after-1", + change_type="M", + ) + ], + "destination-repository": [ + MagicMock( + before_blob_path="two.py", + before_blob_id="before-2", + after_blob_path="two.py", + after_blob_id="after-2", + change_type="M", + ) + ], + } + + def get_differences(repository, comparison_base, _source_commit): + differences = differences_by_repository[repository] + if comparison_base.startswith("destination-"): + return [ + MagicMock( + before_blob_path="target-only.py", + before_blob_id="target-only-before", + after_blob_path="", + after_blob_id="", + change_type="D", + ), + *differences, + ] + return differences + + provider.codecommit_client.get_differences.side_effect = get_differences provider.pr = provider._get_pr() files = provider.get_files() @@ -248,20 +291,23 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): "source-repository", "destination-repository" ] assert [file.filename for file in files] == ["one.py", "two.py"] - assert [(file.repository_name, file.destination_commit, file.source_commit) for file in files] == [ - ("source-repository", "destination-commit-1", "source-commit-1"), - ("destination-repository", "destination-commit-2", "source-commit-2"), + assert [ + (file.repository_name, file.comparison_base_commit, file.destination_commit, file.source_commit) + for file in files + ] == [ + ("source-repository", "merge-base-1", "destination-commit-1", "source-commit-1"), + ("destination-repository", "merge-base-2", "destination-commit-2", "source-commit-2"), ] assert provider.codecommit_client.get_differences.call_args_list == [ - call("source-repository", "destination-commit-1", "source-commit-1"), - call("destination-repository", "destination-commit-2", "source-commit-2"), + call("source-repository", "merge-base-1", "source-commit-1"), + call("destination-repository", "merge-base-2", "source-commit-2"), ] provider.codecommit_client.get_file.side_effect = ( lambda repository, path, commit: { - ("source-repository", "one.py", "destination-commit-1"): b"before one\n", + ("source-repository", "one.py", "merge-base-1"): b"before one\n", ("source-repository", "one.py", "source-commit-1"): b"after one\n", - ("destination-repository", "two.py", "destination-commit-2"): b"before two\n", + ("destination-repository", "two.py", "merge-base-2"): b"before two\n", ("destination-repository", "two.py", "source-commit-2"): b"after two\n", }[(repository, path, commit)] ) @@ -272,6 +318,27 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): ("two.py", "before two\n", "after two\n"), ] + def test_get_files_falls_back_to_destination_when_merge_base_is_missing(self): + provider = object.__new__(CodeCommitProvider) + provider.repo_name = "source-repository" + provider.git_files = None + provider.codecommit_client = MagicMock() + provider.codecommit_client.get_differences.return_value = [] + target = SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit", + destination_commit="destination-commit", + merge_base="", + ) + provider.pr = PullRequestCCMimic("Legacy target", [], targets=[target]) + provider.pr.source_commit = target.source_commit + provider.pr.destination_commit = target.destination_commit + + assert provider.get_files() == [] + provider.codecommit_client.get_differences.assert_called_once_with( + "source-repository", "destination-commit", "source-commit" + ) + def test_prepare_comment_body_caps_at_codecommit_limit(self): # PostCommentForPullRequest rejects bodies above 10,240 characters and # publish_comment raises instead of degrading (#3272). The cap must be @@ -355,11 +422,13 @@ def test_publish_comment_uses_every_pull_request_target(self): repository_name="source-repository", source_commit="source-commit-1", destination_commit="destination-commit-1", + merge_base="merge-base-1", ), MagicMock( repository_name="destination-repository", source_commit="source-commit-2", destination_commit="destination-commit-2", + merge_base="merge-base-2", ), ], ) @@ -391,6 +460,7 @@ def test_persistent_review_creates_then_updates_each_pull_request_target(self): source_branch="refs/heads/feature", destination_commit="destination-commit-1", destination_branch="refs/heads/main", + merge_base="merge-base-1", ), SimpleNamespace( repository_name="source-repository", @@ -398,6 +468,7 @@ def test_persistent_review_creates_then_updates_each_pull_request_target(self): source_branch="refs/heads/feature", destination_commit="destination-commit-2", destination_branch="refs/heads/release", + merge_base="merge-base-2", ), ] provider = self._make_persistent_provider(targets=targets) @@ -868,11 +939,13 @@ def test_publish_code_suggestion_uses_target_that_contains_file(self): repository_name="source-repository", source_commit="source-commit-1", destination_commit="destination-commit-1", + merge_base="merge-base-1", ), MagicMock( repository_name="destination-repository", source_commit="source-commit-2", destination_commit="destination-commit-2", + merge_base="merge-base-2", ), ], ) @@ -886,6 +959,7 @@ def test_publish_code_suggestion_uses_target_that_contains_file(self): repository_name="destination-repository", source_commit="source-commit-2", destination_commit="destination-commit-2", + comparison_base_commit="merge-base-2", ) ] From 05b5f170a53ddb8d010be7e6dab25c2c6c79a99b Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 18 Sep 2026 14:58:41 -0500 Subject: [PATCH 168/238] fix(gitlab): migrate to paginated merge request diffs (GitLab 15.7 or later) (#3491) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> Co-authored-by: IsmaelMartinez --- docs/docs/installation/gitlab.md | 22 +- pr_agent/git_providers/gitlab_provider.py | 101 +++-- pr_agent/tools/pr_code_suggestions.py | 2 +- .../test_gitlab_diff_version_fetch.py | 3 +- tests/unittest/test_gitlab_diffs_api.py | 372 ++++++++++++++++++ tests/unittest/test_gitlab_provider.py | 189 +++++---- tests/unittest/test_per_directory_settings.py | 7 +- 7 files changed, 559 insertions(+), 137 deletions(-) create mode 100644 tests/unittest/test_gitlab_diffs_api.py diff --git a/docs/docs/installation/gitlab.md b/docs/docs/installation/gitlab.md index 447bfb5687..c310e083bf 100644 --- a/docs/docs/installation/gitlab.md +++ b/docs/docs/installation/gitlab.md @@ -1,3 +1,21 @@ +## Merge request diff limits + +PR-Agent requires GitLab 15.7 or later and retrieves all pages from the merge request +[`/diffs` endpoint](https://docs.gitlab.com/api/merge_requests/#list-merge-request-diffs). +Pagination does not bypass GitLab's server-side diff limits. PR-Agent raises a provider +error if the returned file count disagrees with an exact `changes_count`, or that count +indicates overflow or is not ready. A file whose patch GitLab omits (`too_large`, or +`collapsed` before GitLab 19.2) is diffed locally from its two revisions instead. + +The `/diffs` endpoint must be available. PR-Agent does not fall back to the deprecated +`/changes` endpoint or its raw-diff retry. + +PR-Agent reads fresh merge request metadata before and after collecting the diff pages. +If the revision or file count changes during collection, it retries once, then raises a +provider error if they change again. Non-empty results also require usable base/head +references for loading file content. If the merge request moves after incremental +setup, PR-Agent falls back to a full review instead of mixing revisions. + ## Run as a GitLab Pipeline You can use a pre-built Action Docker image to run PR-Agent as a GitLab pipeline. This is a simple way to get started with PR-Agent without setting up your own server. @@ -117,7 +135,7 @@ git clone https://github.com/the-pr-agent/pr-agent.git 2. In the secrets file/variables: - Set your AI model key in the respective section - In the [gitlab] section, set `personal_access_token` (with token from step 2) and `shared_secret` (with secret from step 3) - - **Authentication type**: Set `auth_type` to `"private_token"` for older GitLab versions (e.g., 11.x) or private deployments. Default is `"oauth_token"` for gitlab.com and newer versions. + - **Authentication type**: Set `auth_type` to `"private_token"` to send the token in the `PRIVATE-TOKEN` header, or use the default `"oauth_token"` for the `Authorization: Bearer` header. Both are supported on GitLab.com and self-managed instances. 6. Build a Docker image for the app and optionally push it to a Docker repository. We'll use Dockerhub as an example: @@ -136,7 +154,7 @@ CONFIG__GIT_PROVIDER=gitlab GITLAB__PERSONAL_ACCESS_TOKEN= GITLAB__SHARED_SECRET= GITLAB__URL=https://gitlab.com -GITLAB__AUTH_TYPE=oauth_token # Use "private_token" for older GitLab versions +GITLAB__AUTH_TYPE=oauth_token # Use "private_token" for the PRIVATE-TOKEN header OPENAI__KEY= PORT=3000 # Optional: override the webhook server port ``` diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 3e846f633c..5df461bada 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -48,6 +48,10 @@ class DiffNotFoundError(Exception): pass +class IncompleteGitLabDiffError(DiffNotFoundError): + """Represent an incomplete GitLab merge-request diff response.""" + + def _parse_gitlab_iso_datetime(value) -> Optional[datetime]: """Parse a GitLab ISO 8601 datetime string into a naive UTC datetime. @@ -261,7 +265,7 @@ def __init__(self, merge_request_url: Optional[str] = None, incremental: Optiona self.incremental = incremental # --- submodule expansion helpers (opt-in) --- - def _get_gitmodules_map(self) -> dict[str, str]: + def _get_gitmodules_map(self, diff_refs: dict | None = None) -> dict[str, str]: """ Return {submodule_path -> repo_url} from '.gitmodules' (best effort). Reads the MR head commit first (it carries the submodule URLs the MR introduces, e.g. after a @@ -273,7 +277,8 @@ def _get_gitmodules_map(self) -> dict[str, str]: except Exception: return {} - diff_refs = getattr(self.mr, "diff_refs", None) + if diff_refs is None: + diff_refs = getattr(self.mr, "diff_refs", None) diff_refs = diff_refs if isinstance(diff_refs, dict) else {} def _source_project(): @@ -459,7 +464,7 @@ def _compare_submodule(self, proj_path: str, old_sha: str, new_sha: str) -> list self._submodule_cache[key] = [] return [] - def _expand_submodule_changes(self, changes: list[dict]) -> list[dict]: + def _expand_submodule_changes(self, changes: list[dict], diff_refs: dict | None = None) -> list[dict]: """ If enabled, expand 'Subproject commit' bumps into real file diffs from the submodule. Soft-fail on any issue. @@ -470,7 +475,7 @@ def _expand_submodule_changes(self, changes: list[dict]) -> list[dict]: except Exception: return changes - gitmodules = self._get_gitmodules_map() + gitmodules = self._get_gitmodules_map(diff_refs) if not gitmodules: return changes @@ -520,15 +525,38 @@ def _expand_submodule_changes(self, changes: list[dict]) -> list[dict]: return out def _get_merge_request_changes(self) -> dict: - """Retrieve the complete merge request change set when GitLab reports overflow.""" - changes = self.mr.changes() - if isinstance(changes, dict) and changes.get("overflow"): - get_logger().warning( - f"GitLab returned an overflowed diff for merge request {self.id_mr}; " - "retrying with access_raw_diffs=True" - ) - return self.mr.changes(access_raw_diffs=True) - return changes + """Collect all MR diff pages with stable metadata and their matching refs.""" + project_id = quote(str(self.id_project), safe="") + path = f"/projects/{project_id}/merge_requests/{self.id_mr}" + for attempt in range(2): + before = self.gl.http_get(path) + changes = self.gl.http_list(f"{path}/diffs", get_all=True) + after = self.gl.http_get(path) + if any(before.get(key) != after.get(key) for key in ("sha", "diff_refs", "changes_count")): + if attempt == 0: + continue + raise IncompleteGitLabDiffError( + f"GitLab merge request {self.id_mr} changed while collecting its diff pages" + ) + + changes_count = after.get("changes_count") + if isinstance(changes_count, str): + if not changes_count or changes_count.endswith("+"): + raise IncompleteGitLabDiffError( + f"GitLab merge request {self.id_mr} diff collection is incomplete or not ready" + ) + if changes_count.isdecimal() and len(changes) != int(changes_count): + raise IncompleteGitLabDiffError( + f"GitLab returned {len(changes)} merge-request files but reported {changes_count}" + ) + diff_refs = after.get("diff_refs") + if changes and (not isinstance(diff_refs, dict) or any( + not isinstance(diff_refs.get(key), str) or not diff_refs[key] for key in ("base_sha", "head_sha") + )): + raise IncompleteGitLabDiffError( + f"GitLab merge request {self.id_mr} diff refs are not ready" + ) + return {"changes": changes, "diff_refs": diff_refs if isinstance(diff_refs, dict) else {}} def is_supported(self, capability: str) -> bool: if capability in ['create_inline_comment', 'publish_inline_comments']: # gfm_markdown is supported in gitlab ! @@ -628,6 +656,7 @@ def get_incremental_commits(self, incremental: Optional[IncrementalPR] = None, k # a diff computed under a different incremental scope (or none). Invalidate it so the # next get_diff_files() call reflects the scope configured here. self.diff_files = None + self.mr_commits = None if not self.incremental.is_incremental: return self.unreviewed_files_map = {} @@ -673,7 +702,8 @@ def _get_incremental_commits(self): last_seen_sha = self.incremental.last_seen_commit_sha try: - head_sha = self.mr.diff_refs['head_sha'] + compare_refs = dict(self.mr.diff_refs) + head_sha = compare_refs['head_sha'] except (KeyError, TypeError, AttributeError): head_sha = None self._incremental_head_sha = head_sha @@ -709,18 +739,29 @@ def _get_incremental_commits(self): # via the merge) appear in `diffs` — even though they are not part of the MR's own # contribution and would never appear in a full /review. # - # `mr.changes()` is anchored on the MR's merge-base with target, so it correctly excludes - # target-side changes. Intersect file paths to drop "phantom" files brought in via merge. + # Use the MR diff anchored on the merge-base with target to exclude target-side changes. + # Intersect file paths to drop "phantom" files brought in via merge. mr_change_paths = None try: + mr_changes = self._get_merge_request_changes() + if any(mr_changes["diff_refs"].get(key) != compare_refs.get(key) + for key in ("base_sha", "start_sha", "head_sha")): + get_logger().info("MR diff refs changed since incremental setup; falling back to a full run") + self.unreviewed_files_map = {} + self.git_files = None + self.diff_files = None + self.incremental.is_incremental = False + return mr_change_paths = { c.get('new_path') - for c in self._get_merge_request_changes().get('changes', []) + for c in mr_changes.get('changes', []) if c.get('new_path') } + except IncompleteGitLabDiffError: + raise except Exception as e: get_logger().warning( - f"Could not fetch mr.changes() to filter incremental scope; " + f"Could not fetch MR diffs to filter incremental scope; " f"merge-from-target changes may leak into the review: {e}" ) @@ -915,20 +956,20 @@ def get_diff_files(self) -> list[FilePatchInfo]: if incremental_active: raw_changes = list(self.unreviewed_files_map.values()) - # Apply submodule expansion symmetrically with the full-review path so that - # `GITLAB.EXPAND_SUBMODULE_DIFFS` keeps working under `/review -i`. - raw_changes = self._expand_submodule_changes(raw_changes) base_sha_for_content = self.incremental.last_seen_commit_sha # `_incremental_head_sha` is populated by `_get_incremental_commits()` whenever # incremental_active is true; we still guard for defensive callers. head_sha_for_content = getattr(self, '_incremental_head_sha', None) if not head_sha_for_content: head_sha_for_content = (self.mr.diff_refs or {}).get('head_sha') + diff_refs = {"base_sha": base_sha_for_content, "head_sha": head_sha_for_content} else: - raw_changes = self._get_merge_request_changes().get('changes', []) - raw_changes = self._expand_submodule_changes(raw_changes) - base_sha_for_content = self.mr.diff_refs['base_sha'] - head_sha_for_content = self.mr.diff_refs['head_sha'] + mr_changes = self._get_merge_request_changes() + raw_changes = mr_changes.get('changes', []) + diff_refs = mr_changes["diff_refs"] + base_sha_for_content = diff_refs.get('base_sha') + head_sha_for_content = diff_refs.get('head_sha') + raw_changes = self._expand_submodule_changes(raw_changes, diff_refs) diffs_original = raw_changes diffs = filter_ignored(diffs_original, 'gitlab') if diffs != diffs_original: @@ -1003,8 +1044,8 @@ def get_files(self) -> list: and getattr(self, 'unreviewed_files_map', None)): return list(self.unreviewed_files_map.keys()) if not self.git_files: - raw_changes = self._get_merge_request_changes().get('changes', []) - raw_changes = self._expand_submodule_changes(raw_changes) + mr_changes = self._get_merge_request_changes() + raw_changes = self._expand_submodule_changes(mr_changes.get('changes', []), mr_changes["diff_refs"]) self.git_files = [c.get('new_path') for c in raw_changes if c.get('new_path')] return self.git_files @@ -1016,8 +1057,8 @@ def get_pr_file_paths(self) -> list: which files the review already covered. Discovery instead walks the full MR changes, keeping both old_path and new_path so both sides of a rename apply. """ - raw_changes = self._get_merge_request_changes().get('changes', []) - raw_changes = self._expand_submodule_changes(raw_changes) + mr_changes = self._get_merge_request_changes() + raw_changes = self._expand_submodule_changes(mr_changes.get('changes', []), mr_changes["diff_refs"]) return [c for c in raw_changes if c.get('new_path') or c.get('old_path')] def publish_description(self, pr_title: str, pr_body: str) -> None: @@ -1433,7 +1474,7 @@ def _create_suggestion_note(self, as_draft: bool, body: str, pos_obj: dict, diff def get_relevant_diff(self, relevant_file: str, relevant_line_in_file: str) -> Optional[dict]: _changes = self._get_merge_request_changes() - _changes['changes'] = self._expand_submodule_changes(_changes.get('changes', [])) + _changes['changes'] = self._expand_submodule_changes(_changes.get('changes', []), _changes["diff_refs"]) changes = _changes if not changes: get_logger().error('No changes found for the merge request.') diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 84dbbba695..a1fa4116b8 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -152,7 +152,7 @@ def __init__(self, pr_url: str, cli_mode=False, args: list = None, self._setup_incremental_scope() # If incremental is active but the scope came back empty (no files changed since the # previous suggestions pass), short-circuit init now. `run()` checks the same flag and - # exits without touching the model. This avoids a wasted `mr.changes()` round-trip via + # exits without touching the model. This avoids a wasted full MR-diff retrieval via # `get_files()` — when `unreviewed_files_map` is `{}` it's falsy and `get_files()` falls # back to the full MR file list, which is pure waste on the "nothing new" path. if (self.incremental.is_incremental diff --git a/tests/unittest/test_gitlab_diff_version_fetch.py b/tests/unittest/test_gitlab_diff_version_fetch.py index b5874fa333..4081eda1d8 100644 --- a/tests/unittest/test_gitlab_diff_version_fetch.py +++ b/tests/unittest/test_gitlab_diff_version_fetch.py @@ -31,8 +31,9 @@ def test_get_relevant_diff_fetches_only_latest_diff_version(): provider.last_diff = latest_diff provider._get_merge_request_changes = Mock(return_value={ "changes": [{"new_path": "src/app.py", "diff": "@@\n+hello"}], + "diff_refs": {"base_sha": "base", "head_sha": "head"}, }) - provider._expand_submodule_changes = Mock(side_effect=lambda changes: changes) + provider._expand_submodule_changes = Mock(side_effect=lambda changes, diff_refs=None: changes) assert provider.get_relevant_diff("src/app.py", "hello") is latest_diff diff --git a/tests/unittest/test_gitlab_diffs_api.py b/tests/unittest/test_gitlab_diffs_api.py new file mode 100644 index 0000000000..22d70d8d37 --- /dev/null +++ b/tests/unittest/test_gitlab_diffs_api.py @@ -0,0 +1,372 @@ +"""Exercise MR diff retrieval through python-gitlab's HTTP and pagination code.""" +import json +from types import SimpleNamespace +from unittest.mock import Mock, call +from urllib.parse import urlparse + +import gitlab +import pytest +import requests +from requests.adapters import BaseAdapter + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.git_provider import IncrementalPR +from pr_agent.git_providers.gitlab_provider import GitLabProvider, IncompleteGitLabDiffError + + +def _change(path, **kwargs): + return { + "old_path": path, "new_path": path, "diff": "@@ -1 +1 @@\n-old\n+new\n", + "new_file": False, "deleted_file": False, "renamed_file": False, + **kwargs, + } + + +class DiffTransport(BaseAdapter): + def __init__(self, responses, metadata, metadata_responses=None): + super().__init__() + self.responses = iter(responses) + self.metadata = metadata + self.metadata_responses = iter(metadata_responses) if metadata_responses is not None else None + self.requests = [] + + @property + def diff_requests(self): + return [request for request in self.requests if urlparse(request.url).path.endswith("/diffs")] + + def send(self, request, **kwargs): + self.requests.append(request) + assert request.method == "GET" + if urlparse(request.url).path.endswith("/diffs"): + status, payload, headers = next(self.responses) + else: + assert urlparse(request.url).path.endswith("/merge_requests/7") + status, payload, headers = ( + next(self.metadata_responses) if self.metadata_responses is not None else (200, self.metadata, {}) + ) + response = requests.Response() + response.status_code = status + response._content = json.dumps(payload).encode() + response.headers.update({"Content-Type": "application/json", **headers}) + response.request = request + return response + + def close(self): + pass + + +@pytest.fixture +def provider_factory(): + sessions = [] + + def make(responses, count="2", project_id="group/sub/repo", metadata_responses=None): + metadata = _metadata(count=count) + transport = DiffTransport(responses, metadata, metadata_responses) + session = requests.Session() + session.mount("https://", transport) + sessions.append(session) + provider = GitLabProvider.__new__(GitLabProvider) + provider.gl = gitlab.Gitlab("https://gitlab.example/gitlab", private_token="offline-token", session=session) + provider.id_project = project_id + provider.id_mr = 7 + project = provider.gl.projects.get(project_id, lazy=True) + provider.mr = project.mergerequests.get(7, lazy=True) + if count is not None: + provider.mr.changes_count = count + provider.mr.diff_refs = {"base_sha": "base", "start_sha": "start", "head_sha": "head"} + provider.git_files = None + provider.diff_files = None + provider.incremental = IncrementalPR(False) + provider._expand_submodule_changes = Mock(side_effect=lambda changes, diff_refs: changes) + provider.get_pr_file_content = Mock(side_effect=lambda path, ref: "old\n" if ref == "base" else "new\n") + return provider, transport + + yield make + for session in sessions: + session.close() + + +def _metadata(count="2", **refs): + return { + **({"changes_count": count} if count is not None else {}), + "sha": refs.get("head_sha", "head"), + "diff_refs": {"base_sha": "base", "start_sha": "start", "head_sha": "head", **refs}, + } + + +def _metadata_reads(*snapshots): + return [(200, snapshot, {}) for snapshot in snapshots] + + +def _pages(first, second): + next_url = "https://gitlab.example/gitlab/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/diffs?page=2" + return [(200, first, {"Link": f'<{next_url}>; rel="next"'}), (200, second, {})] + + +def test_collects_all_pages_using_the_configured_client(provider_factory): + renamed = _change("new.py", old_path="old.py", renamed_file=True) + provider, transport = provider_factory(_pages([_change("first.py")], [renamed])) + + files = provider.get_diff_files() + + assert [file.filename for file in files] == ["first.py", "new.py"] + assert files[1].old_filename == "old.py" + assert [urlparse(request.url).path for request in transport.diff_requests] == [ + "/gitlab/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/diffs", + "/gitlab/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/diffs", + ] + assert urlparse(transport.diff_requests[1].url).query == "page=2" + assert len(transport.requests) == 4 + assert all(urlparse(transport.requests[index].url).path.endswith("/merge_requests/7") for index in (0, 3)) + assert all(request.headers["PRIVATE-TOKEN"] == "offline-token" for request in transport.requests) + assert provider.get_diff_files() is files + assert len(transport.requests) == 4 + + +@pytest.mark.parametrize("project_id", [123, "123"]) +def test_numeric_project_identifier(provider_factory, project_id): + provider, transport = provider_factory([(200, [_change("a.py")], {})], "1", project_id) + assert provider.get_files() == ["a.py"] + assert urlparse(transport.diff_requests[0].url).path.endswith("/projects/123/merge_requests/7/diffs") + + +@pytest.mark.parametrize("flag", ["too_large", "collapsed"]) +def test_pruned_patch_on_later_page_is_reconstructed_from_both_blobs(provider_factory, flag): + provider, transport = provider_factory(_pages([_change("visible.py")], [_change("pruned.py", diff="", **{flag: True})])) + + files = provider.get_diff_files() + + assert [file.filename for file in files] == ["visible.py", "pruned.py"] + assert "-old" in files[1].patch and "+new" in files[1].patch + provider.get_pr_file_content.assert_has_calls([call("pruned.py", "base"), call("pruned.py", "head")]) + assert len(transport.diff_requests) == 2 + + +@pytest.mark.parametrize("count", ["1000+", "1+", "3", "0", ""]) +def test_incomplete_or_unready_collection_is_not_cached(provider_factory, count): + provider, _ = provider_factory([(200, [_change("a.py"), _change("b.py")], {})], count) + with pytest.raises(IncompleteGitLabDiffError): + provider.get_files() + assert provider.git_files is None + + +def test_absent_count_is_distinct_from_an_unready_empty_count(provider_factory): + provider, _ = provider_factory([(200, [_change("a.py")], {})], count=None) + assert provider.get_files() == ["a.py"] + + +def test_complete_empty_merge_request(provider_factory): + provider, _ = provider_factory([(200, [], {})], count="0") + assert provider.get_diff_files() == [] + + +def test_empty_patch_without_omission_flags_retains_reconstruction(provider_factory): + provider, _ = provider_factory([(200, [_change("a.py", diff="")], {})], count="1") + files = provider.get_diff_files() + assert "+new" in files[0].patch + assert "-old" in files[0].patch + + +def test_later_page_failure_does_not_cache_a_prefix_and_can_retry(provider_factory): + pages = _pages([_change("a.py")], [_change("b.py")]) + provider, transport = provider_factory([pages[0], (503, {"message": "unavailable"}, {}), *pages]) + with pytest.raises(gitlab.GitlabHttpError): + provider.get_files() + assert provider.git_files is None + assert provider.get_files() == ["a.py", "b.py"] + assert len(transport.diff_requests) == 4 + + +@pytest.mark.parametrize("method", ["get_files", "get_diff_files", "get_pr_file_paths", "get_relevant_diff"]) +def test_missing_diffs_endpoint_propagates_without_fallback(provider_factory, method): + provider, transport = provider_factory([ + (404, {"message": "original missing endpoint"}, {}), + ]) + args = ["a.py", "new"] if method == "get_relevant_diff" else [] + with pytest.raises(gitlab.GitlabHttpError, match="original missing endpoint") as error: + getattr(provider, method)(*args) + assert error.value.response_code == 404 + assert provider.git_files is None + assert provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + assert [urlparse(request.url).path.rsplit("/", 1)[-1] for request in transport.requests] == ["7", "diffs"] + + +@pytest.mark.parametrize("status", [401, 403, 500]) +def test_other_errors_do_not_probe_version_or_use_legacy(provider_factory, status): + provider, transport = provider_factory([(status, {"message": "denied"}, {})]) + with pytest.raises((gitlab.GitlabHttpError, gitlab.GitlabAuthenticationError)): + provider.get_files() + assert len(transport.requests) == 2 + + +def _prepare_incremental(provider): + provider.mr_commits = [object()] + provider._incremental_kind = "review" + provider._find_anchor_note = Mock(return_value=object()) + provider.get_commit_range = Mock(return_value=[object()]) + provider.incremental = IncrementalPR(True) + provider.incremental.last_seen_commit = SimpleNamespace(sha="previous") + provider.unreviewed_files_map = {} + project = Mock() + project.repository_compare.return_value = {"diffs": [_change("a.py"), _change("b.py"), _change("target-only.py")]} + provider.gl.projects.get = Mock(return_value=project) + + +def test_incremental_membership_uses_all_pages(provider_factory): + provider, _ = provider_factory(_pages([_change("a.py")], [_change("b.py")])) + _prepare_incremental(provider) + provider._get_incremental_commits() + assert set(provider.unreviewed_files_map) == {"a.py", "b.py"} + + +def test_incremental_filter_propagates_known_incomplete_response(provider_factory): + provider, _ = provider_factory([(200, [_change("a.py")], {})], "2") + _prepare_incremental(provider) + with pytest.raises(IncompleteGitLabDiffError): + provider._get_incremental_commits() + assert provider.unreviewed_files_map == {} + + +@pytest.mark.parametrize("status", [404, 503]) +def test_incremental_filter_retains_best_effort_on_http_failure(provider_factory, status): + provider, transport = provider_factory([(status, {"message": "unavailable"}, {})]) + _prepare_incremental(provider) + provider._get_incremental_commits() + assert set(provider.unreviewed_files_map) == {"a.py", "b.py", "target-only.py"} + assert [urlparse(request.url).path.rsplit("/", 1)[-1] for request in transport.requests] == ["7", "diffs"] + + +def test_fresh_metadata_supplies_count_and_blob_refs_without_mutating_cached_mr(provider_factory): + fresh = _metadata(count="1", base_sha="fresh-base", head_sha="fresh-head") + provider, transport = provider_factory( + [(200, [_change("a.py", diff="")], {})], count="1000+", metadata_responses=_metadata_reads(fresh, fresh), + ) + cached_mr = provider.mr + cached_refs = dict(cached_mr.diff_refs) + provider.get_pr_file_content.side_effect = lambda path, ref: "old\n" if ref == "fresh-base" else "new\n" + + files = provider.get_diff_files() + + assert [file.filename for file in files] == ["a.py"] + assert "-old" in files[0].patch and "+new" in files[0].patch + assert provider.mr is cached_mr + assert provider.mr.changes_count == "1000+" + assert provider.mr.diff_refs == cached_refs + assert provider.get_pr_file_content.call_args_list == [call("a.py", "fresh-base"), call("a.py", "fresh-head")] + assert provider._expand_submodule_changes.call_args.args[1] == fresh["diff_refs"] + assert len(transport.requests) == 3 + + +@pytest.mark.parametrize("field", ["sha", "head_sha", "base_sha", "start_sha", "changes_count"]) +def test_moving_metadata_discards_pages_and_retries_once(provider_factory, field): + old = _metadata() + fresh = _metadata() + if field == "changes_count": + old[field] = "1" + elif field == "sha": + fresh[field] = "pushed-head" + else: + fresh["diff_refs"][field] = f"fresh-{field}" + provider, transport = provider_factory( + [*_pages([_change("discard-a.py")], [_change("discard-b.py")]), + *_pages([_change("a.py")], [_change("b.py")])], + metadata_responses=_metadata_reads(old, fresh, fresh, fresh), + ) + + assert provider.get_files() == ["a.py", "b.py"] + assert len(transport.diff_requests) == 4 + assert len(transport.requests) == 8 + + +@pytest.mark.parametrize("method", ["get_files", "get_diff_files", "get_pr_file_paths", "get_relevant_diff"]) +def test_continuous_movement_fails_before_use_and_can_recover(provider_factory, method): + old, middle, latest = (_metadata(count="1", head_sha=head) for head in ("old", "middle", "latest")) + provider, transport = provider_factory( + [(200, [_change("a.py")], {})] * 3, + metadata_responses=_metadata_reads(old, middle, middle, latest, latest, latest), + ) + args = ["a.py", "new"] if method == "get_relevant_diff" else [] + + with pytest.raises(IncompleteGitLabDiffError, match="changed while collecting"): + getattr(provider, method)(*args) + + assert provider.git_files is None and provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + provider._expand_submodule_changes.assert_not_called() + assert len(transport.diff_requests) == 2 + assert provider.get_files() == ["a.py"] + + +@pytest.mark.parametrize("refs", [None, [], {}, {"base_sha": "base"}, {"base_sha": "", "head_sha": "head"}, + {"base_sha": "base", "head_sha": 123}]) +@pytest.mark.parametrize("method", ["get_files", "get_diff_files", "get_pr_file_paths", "get_relevant_diff"]) +def test_nonempty_collection_requires_usable_fresh_refs(provider_factory, refs, method): + metadata = {**_metadata(count="1"), "diff_refs": refs} + provider, _ = provider_factory( + [(200, [_change("a.py")], {})], metadata_responses=_metadata_reads(metadata, metadata), + ) + args = ["a.py", "new"] if method == "get_relevant_diff" else [] + with pytest.raises(IncompleteGitLabDiffError, match="diff refs are not ready"): + getattr(provider, method)(*args) + assert provider.git_files is None and provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + + +def test_empty_collection_does_not_require_blob_refs(provider_factory): + metadata = {"changes_count": "0", "diff_refs": None} + provider, _ = provider_factory([(200, [], {})], metadata_responses=_metadata_reads(metadata, metadata)) + assert provider.get_diff_files() == [] + provider.get_pr_file_content.assert_not_called() + + +@pytest.mark.parametrize("after_pages", [False, True]) +def test_metadata_http_failure_never_caches_or_uses_pages(provider_factory, after_pages): + metadata = _metadata(count="1") + metadata_responses = _metadata_reads(metadata) if after_pages else [] + metadata_responses.append((503, {"message": "metadata unavailable"}, {})) + provider, transport = provider_factory([(200, [_change("a.py")], {})], metadata_responses=metadata_responses) + + with pytest.raises(gitlab.GitlabHttpError, match="metadata unavailable"): + provider.get_diff_files() + + assert provider.git_files is None and provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + assert len(transport.diff_requests) == int(after_pages) + + +@pytest.mark.parametrize("flag", ["too_large", "collapsed"]) +@pytest.mark.parametrize("excluded", ["uv.lock", "ignored.py"]) +def test_flagged_excluded_file_does_not_abort_or_fetch_blobs(provider_factory, monkeypatch, flag, excluded): + monkeypatch.setitem(get_settings().ignore, "glob", ["ignored.py"]) + provider, _ = provider_factory( + _pages([_change("visible.py")], [_change(excluded, diff="", **{flag: True})]), + ) + + assert [file.filename for file in provider.get_diff_files()] == ["visible.py"] + assert all(entry.args[0] != excluded for entry in provider.get_pr_file_content.call_args_list) + + +@pytest.mark.parametrize("ref", ["base_sha", "start_sha", "head_sha"]) +def test_incremental_ref_movement_falls_back_to_fresh_full_collection(provider_factory, ref): + fresh = _metadata(**{ref: "fresh-ref"}) + changes = [_change("a.py", diff=""), _change("b.py")] + provider, transport = provider_factory( + [(200, changes, {})] * 2, metadata_responses=_metadata_reads(fresh, fresh, fresh, fresh), + ) + _prepare_incremental(provider) + provider.git_files = ["stale.py"] + provider.diff_files = [object()] + provider.unreviewed_files_map = {"stale.py": _change("stale.py")} + + provider._get_incremental_commits() + + assert provider.incremental.is_incremental is False + assert provider.git_files is None and provider.diff_files is None + assert provider.unreviewed_files_map == {} + assert [file.filename for file in provider.get_diff_files()] == ["a.py", "b.py"] + provider.get_pr_file_content.assert_has_calls([ + call("a.py", fresh["diff_refs"]["base_sha"]), call("a.py", fresh["diff_refs"]["head_sha"]), + ]) + assert len(transport.diff_requests) == 2 + assert len(transport.requests) == 6 diff --git a/tests/unittest/test_gitlab_provider.py b/tests/unittest/test_gitlab_provider.py index 8c1c60992b..7a24d6290f 100644 --- a/tests/unittest/test_gitlab_provider.py +++ b/tests/unittest/test_gitlab_provider.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import pytest from gitlab import Gitlab @@ -85,6 +85,17 @@ def gitlab_provider(self, mock_gitlab_client, mock_project): provider = GitLabProvider("https://gitlab.com/test/repo/-/merge_requests/1") provider.gl = mock_gitlab_client provider.id_project = "test/repo" + + def _current_mr_metadata(*_args, **_kwargs): + diff_refs = getattr(provider.mr, "diff_refs", None) + changes = mock_gitlab_client.http_list.return_value + return { + "sha": "current-mr", + "diff_refs": dict(diff_refs) if isinstance(diff_refs, dict) else {}, + "changes_count": str(len(changes)) if isinstance(changes, list) else None, + } + + mock_gitlab_client.http_get.side_effect = _current_mr_metadata return provider def test_get_pr_file_content_success(self, gitlab_provider, mock_project): @@ -310,6 +321,24 @@ def test_get_gitmodules_map_reads_mr_head_sha(self, gitlab_provider, mock_projec # The fork project is not looked up when the head commit could be read from the target project. assert all(call.args[0] != 99 for call in gitlab_provider.gl.projects.get.call_args_list) + def test_get_gitmodules_map_uses_provided_snapshot_refs(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr( + diff_refs={"head_sha": "cached-head", "base_sha": "cached-base"}, source_project_id=99 + ) + mock_project.id = 1 + mock_project.files.get.side_effect = lambda file_path, ref: { + "snapshot-head": self._gitmodules_file("../snapshot/a.git"), + "cached-head": self._gitmodules_file("../cached/a.git"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + + result = gitlab_provider._get_gitmodules_map( + {"head_sha": "snapshot-head", "base_sha": "snapshot-base"} + ) + + assert result == {"libs/a": "../snapshot/a.git"} + mock_project.files.get.assert_called_once_with(file_path=".gitmodules", ref="snapshot-head") + def test_get_gitmodules_map_falls_back_to_source_branch_without_diff_refs(self, gitlab_provider, mock_project): gitlab_provider.mr = self._mr(diff_refs=None, source_project_id=mock_project.id) mock_project.files.get.side_effect = lambda file_path, ref: { @@ -1409,6 +1438,16 @@ def gitlab_provider(self, mock_gitlab_client, mock_project): provider.mr = MagicMock() provider.mr.web_url = "https://gitlab.com/test/repo/-/merge_requests/1" provider.mr.diff_refs = {"base_sha": "base", "head_sha": "head", "start_sha": "base"} + + def _current_mr_metadata(*_args, **_kwargs): + changes = mock_gitlab_client.http_list.return_value + return { + "sha": "current-mr", + "diff_refs": dict(provider.mr.diff_refs), + "changes_count": str(len(changes)) if isinstance(changes, list) else None, + } + + mock_gitlab_client.http_get.side_effect = _current_mr_metadata return provider @staticmethod @@ -1462,11 +1501,9 @@ def test_get_incremental_commits_picks_commits_after_review(self, gitlab_provide "new_file": True, "deleted_file": False, "renamed_file": False}, ] } - # mr.changes() is intersected with repository_compare to exclude files brought in - # via a merge from the target branch. Here both files are part of the MR. - gitlab_provider.mr.changes.return_value = { - "changes": [{"new_path": "a.py"}, {"new_path": "b.py"}] - } + # Intersect MR diffs with repository_compare to exclude files merged from the target branch. + # Include both fixture files in the MR's own changes. + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}, {"new_path": "b.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True)) @@ -1511,93 +1548,16 @@ def test_get_files_uses_incremental_set_when_active(self, gitlab_provider): gitlab_provider.unreviewed_files_map = {"a.py": {"new_path": "a.py"}} assert gitlab_provider.get_files() == ["a.py"] + gitlab_provider.gl.http_list.assert_not_called() gitlab_provider.mr.changes.assert_not_called() - def test_get_files_falls_back_to_mr_changes_when_not_incremental(self, gitlab_provider): + def test_get_files_fetches_mr_diffs_when_not_incremental(self, gitlab_provider): gitlab_provider.incremental = IncrementalPR(False) gitlab_provider.git_files = None - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "x.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "x.py"}] assert gitlab_provider.get_files() == ["x.py"] - def test_get_files_retries_raw_diffs_when_changes_overflow(self, gitlab_provider): - gitlab_provider.git_files = None - gitlab_provider.mr.changes.side_effect = [ - {"changes": [{"new_path": "visible.py"}], "overflow": True}, - { - "changes": [ - {"new_path": "visible.py"}, - {"new_path": "hidden.py"}, - ], - "overflow": False, - }, - ] - - assert gitlab_provider.get_files() == ["visible.py", "hidden.py"] - assert gitlab_provider.mr.changes.call_args_list == [ - call(), - call(access_raw_diffs=True), - ] - - def test_get_diff_files_retries_raw_diffs_when_changes_overflow(self, gitlab_provider): - change = { - "old_path": "visible.py", - "new_path": "visible.py", - "diff": "@@ -1 +1 @@\n-old\n+new\n", - "new_file": False, - "deleted_file": False, - "renamed_file": False, - } - hidden_change = {**change, "old_path": "hidden.py", "new_path": "hidden.py"} - gitlab_provider.mr.changes.side_effect = [ - {"changes": [change], "overflow": True}, - {"changes": [change, hidden_change], "overflow": False}, - ] - gitlab_provider.get_pr_file_content = MagicMock(return_value="") - - diff_files = gitlab_provider.get_diff_files() - - assert [file.filename for file in diff_files] == ["visible.py", "hidden.py"] - assert gitlab_provider.mr.changes.call_args_list == [ - call(), - call(access_raw_diffs=True), - ] - - def test_incremental_scope_retries_raw_diffs_when_changes_overflow( - self, gitlab_provider, mock_project - ): - gitlab_provider.mr.notes.list.return_value = [ - self._make_note(7, "## PR Reviewer Guide 🔍\nbody", "2024-05-01T10:00:00Z"), - ] - gitlab_provider.mr.commits.return_value = [ - self._make_commit("c1", "2024-05-01T11:00:00Z"), - self._make_commit("c0", "2024-05-01T09:00:00Z"), - ] - mock_project.repository_compare.return_value = { - "diffs": [ - {"new_path": "visible.py"}, - {"new_path": "hidden.py"}, - ] - } - gitlab_provider.mr.changes.side_effect = [ - {"changes": [{"new_path": "visible.py"}], "overflow": True}, - { - "changes": [ - {"new_path": "visible.py"}, - {"new_path": "hidden.py"}, - ], - "overflow": False, - }, - ] - - gitlab_provider.get_incremental_commits(IncrementalPR(True)) - - assert set(gitlab_provider.unreviewed_files_map) == {"visible.py", "hidden.py"} - assert gitlab_provider.mr.changes.call_args_list == [ - call(), - call(access_raw_diffs=True), - ] - def test_get_previous_review_returns_most_recent_match(self, gitlab_provider): # GitLab returns notes in created_at-DESC order. The helper relies on that order # (no local sort) — the unrelated newest note must be skipped, the newer matching @@ -1633,9 +1593,9 @@ def test_get_previous_review_accepts_custom_heading_with_stable_identity(self, g def test_master_merge_files_are_excluded_from_incremental_scope(self, gitlab_provider, mock_project): # Reproduction of the MR !1115 bug: user ran `git merge master` on the feature branch, # which brought CI/config changes into the branch via a merge commit. Those files are - # NOT part of mr.changes() (the MR's actual contribution against its merge-base), but + # NOT part of the MR diffs (the MR's actual contribution against its merge-base), but # repository_compare(last_seen, head) walks through the merge and surfaces them. - # The fix intersects with mr.changes() to drop these "phantom" files. + # Intersect with the MR diffs to drop these "phantom" files. gitlab_provider.mr.notes.list.return_value = [ self._make_note(7, "## PR Reviewer Guide 🔍\nbody", "2024-05-01T10:00:00Z"), ] @@ -1654,11 +1614,9 @@ def test_master_merge_files_are_excluded_from_incremental_scope(self, gitlab_pro "new_file": False, "deleted_file": False, "renamed_file": False}, ] } - # mr.changes() returns only the MR's actual contribution; .gitlab-ci.yml is absent + # Include only the MR's actual contribution in its diffs; exclude .gitlab-ci.yml # because the change to it lives in the target branch already. - gitlab_provider.mr.changes.return_value = { - "changes": [{"new_path": "src/feature.js"}] - } + gitlab_provider.gl.http_list.return_value = [{"new_path": "src/feature.js"}] gitlab_provider.get_incremental_commits(IncrementalPR(True)) @@ -1758,7 +1716,7 @@ def test_incremental_kind_suggestions_anchors_on_suggestion_note(self, gitlab_pr "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1782,7 +1740,7 @@ def test_object_shaped_compare_diffs_are_normalized_to_dicts(self, gitlab_provid obj_diff = SimpleNamespace(new_path="a.py", old_path="a.py", diff="@@ ... @@", new_file=False, deleted_file=False, renamed_file=False) mock_project.repository_compare.return_value = SimpleNamespace(diffs=[obj_diff]) - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True)) @@ -1812,7 +1770,7 @@ def test_anchor_note_from_another_user_is_skipped(self, gitlab_provider, mock_pr "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1837,7 +1795,7 @@ def test_anchor_author_check_fails_open_when_user_unresolvable(self, gitlab_prov "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1877,6 +1835,26 @@ def test_get_incremental_commits_invalidates_cached_diff_files(self, gitlab_prov assert gitlab_provider.diff_files is None + def test_get_incremental_commits_reloads_commits_for_each_public_setup(self, gitlab_provider, mock_project): + gitlab_provider.mr.notes.list.return_value = [ + self._make_note(7, "## PR Reviewer Guide 🔍\nbody", "2024-05-01T10:00:00Z"), + ] + gitlab_provider.mr.commits.return_value = [ + self._make_commit("c1", "2024-05-01T11:00:00Z"), + self._make_commit("c0", "2024-05-01T09:00:00Z"), + ] + mock_project.repository_compare.return_value = { + "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", + "new_file": False, "deleted_file": False, "renamed_file": False}], + } + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] + + gitlab_provider.get_incremental_commits(IncrementalPR(True)) + gitlab_provider.get_incremental_commits(IncrementalPR(True)) + + assert gitlab_provider.mr.commits.call_count == 2 + assert mock_project.repository_compare.call_count == 2 + def test_incremental_suggestions_anchor_advances_with_in_place_edits(self, gitlab_provider, mock_project): # Default /improve config (persistent_comment=true, commitable_code_suggestions=false) # EDITS the "## PR Code Suggestions ✨" summary note in place on every run, so its @@ -1897,7 +1875,7 @@ def test_incremental_suggestions_anchor_advances_with_in_place_edits(self, gitla "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1932,7 +1910,7 @@ def test_incremental_suggestions_uses_newest_stable_or_legacy_anchor( "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1967,7 +1945,7 @@ def test_incremental_suggestions_uses_latest_activity_across_stable_and_legacy_a "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -2027,7 +2005,7 @@ def test_incremental_suggestions_uses_newer_custom_no_suggestions_result( "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -2142,7 +2120,16 @@ def _provider(self, changes): provider = GitLabProvider.__new__(GitLabProvider) provider.mr = MagicMock() provider.mr.diffs.list.return_value = list(self.VERSIONS) - provider.mr.changes.return_value = {"changes": changes} + provider.mr.diff_refs = {"base_sha": "base", "head_sha": "head", "start_sha": "base"} + provider.gl = MagicMock() + provider.gl.http_list.return_value = changes + provider.gl.http_get.side_effect = lambda *_args, **_kwargs: { + "sha": "current-mr", + "diff_refs": dict(provider.mr.diff_refs), + "changes_count": str(len(changes)), + } + provider.id_project = "group/repo" + provider.id_mr = 1 provider.last_diff = "latest-at-construction" return provider diff --git a/tests/unittest/test_per_directory_settings.py b/tests/unittest/test_per_directory_settings.py index 08eb06f1f1..b6979afbc1 100644 --- a/tests/unittest/test_per_directory_settings.py +++ b/tests/unittest/test_per_directory_settings.py @@ -1210,8 +1210,11 @@ def test_get_pr_file_paths_returns_full_changes_with_rename_metadata(self): {"new_path": "services/api.py", "old_path": "legacy/api.py"}, {"new_path": "services/fresh.py", "old_path": "services/fresh.py"}, ] - provider._get_merge_request_changes = MagicMock(return_value={"changes": changes}) - provider._expand_submodule_changes = lambda ch: ch + provider._get_merge_request_changes = MagicMock(return_value={ + "changes": changes, + "diff_refs": {"base_sha": "base", "head_sha": "head"}, + }) + provider._expand_submodule_changes = lambda ch, diff_refs=None: ch result = provider.get_pr_file_paths() From b1634d6988af160d2f12b158d567fa8742e9498a Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Fri, 18 Sep 2026 15:07:40 -0500 Subject: [PATCH 169/238] fix(azure): retry renamed files at incremental checkpoint (#3489) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- .../git_providers/azuredevops_provider.py | 35 ++++- tests/unittest/test_azure_devops_provider.py | 144 ++++++++++++++++-- 2 files changed, 163 insertions(+), 16 deletions(-) diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index 81603b0d21..ca14e89b2c 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -60,6 +60,8 @@ def _is_not_found_error(error: Exception) -> bool: status_code = getattr(response, "status_code", None) if status_code is not None: return status_code == 404 + if getattr(error, "type_key", None) == "GitItemNotFoundException": + return True return re.search(r"\b404\b", str(error)) is not None @@ -1008,11 +1010,34 @@ def get_diff_files(self) -> list[FilePatchInfo]: ) original_file_content_str = inc_original.content or "" except Exception as error: - get_logger().warning( - f"Failed to retrieve original of {old_filename or file} " - f"at {self.incremental.last_seen_commit_sha}: {error}" - ) - original_file_content_str = "" + if ( + edit_type == EDIT_TYPE.RENAMED + and old_filename != file + and _is_not_found_error(error) + ): + try: + inc_original = self.azure_devops_client.get_item( + repository_id=self.repo_slug, + path=file, + project=self.workspace_slug, + version_descriptor=inc_version, + download=False, + include_content=True, + ) + original_file_content_str = inc_original.content or "" + except Exception as retry_error: + get_logger().warning( + f"Failed to retrieve original of {old_filename} " + f"at {self.incremental.last_seen_commit_sha}; " + f"retry at {file} also failed: {retry_error}" + ) + original_file_content_str = "" + else: + get_logger().warning( + f"Failed to retrieve original of {old_filename or file} " + f"at {self.incremental.last_seen_commit_sha}: {error}" + ) + original_file_content_str = "" else: base_version = GitVersionDescriptor( version=base_sha.commit_id, version_type="commit" diff --git a/tests/unittest/test_azure_devops_provider.py b/tests/unittest/test_azure_devops_provider.py index d8533a380f..ae9a79e293 100644 --- a/tests/unittest/test_azure_devops_provider.py +++ b/tests/unittest/test_azure_devops_provider.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest +from azure.devops.exceptions import AzureDevOpsServiceError from pr_agent.algo.inline_comment_dedup import code_fingerprint from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo @@ -381,6 +382,42 @@ def _provider_with_pull_request_diff(*get_item_results): change = TestAzureDevopsProviderFiles._change() return TestAzureDevopsProviderFiles._provider_with_change(change, *get_item_results) + @staticmethod + def _status_error(status_code): + error = Exception(f"Operation returned a {status_code} status code.") + error.status_code = status_code + return error + + @staticmethod + def _azure_item_not_found_error(): + wrapped_error = SimpleNamespace( + inner_exception=None, + message="The specified item does not exist at the specified version.", + exception_id=None, + type_name="Microsoft.TeamFoundation.Git.Server.GitItemNotFoundException", + type_key="GitItemNotFoundException", + error_code=0, + event_id=0, + custom_properties=None, + ) + return AzureDevOpsServiceError(wrapped_error) + + @classmethod + def _provider_with_incremental_rename(cls, *get_item_results): + incremental = IncrementalPR(True) + incremental.last_seen_commit = SimpleNamespace(sha="last-seen-sha") + provider = cls._provider_with_change( + cls._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ), + *get_item_results, + incremental=incremental, + ) + provider.unreviewed_files_map = {"/new/name.py": "/new/name.py"} + return provider + def test_get_diff_files_keeps_file_when_new_content_fetch_fails(self): provider = self._provider_with_pull_request_diff( Exception("head fetch failed"), @@ -583,20 +620,10 @@ def test_rename_with_missing_or_malformed_old_path_keeps_empty_base_fallback(sel assert any("/new/name.py" in message and "no usable old path" in message for message in captured) def test_incremental_rename_reads_old_path_at_last_seen_commit(self): - change = self._change( - path="/new/name.py", - change_type="rename", - originalPath="/old/name.py", - ) - incremental = IncrementalPR(True) - incremental.last_seen_commit = SimpleNamespace(sha="last-seen-sha") - provider = self._provider_with_change( - change, + provider = self._provider_with_incremental_rename( SimpleNamespace(content="new\n"), SimpleNamespace(content="old\n"), - incremental=incremental, ) - provider.unreviewed_files_map = {"/new/name.py": "/new/name.py"} diff_file = provider.get_diff_files()[0] @@ -606,6 +633,101 @@ def test_incremental_rename_reads_old_path_at_last_seen_commit(self): assert old_content_call.kwargs["version_descriptor"].version == "last-seen-sha" assert provider.unreviewed_files_map["/new/name.py"] == diff_file.patch + def test_incremental_rename_retries_current_path_when_old_path_is_missing_at_checkpoint(self): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="value = 3\n"), + self._azure_item_not_found_error(), + SimpleNamespace(content="value = 2\n"), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == "value = 2\n" + assert diff_file.head_file == "value = 3\n" + assert "-value = 2" in diff_file.patch + assert "+value = 3" in diff_file.patch + assert provider.unreviewed_files_map["/new/name.py"] == diff_file.patch + calls = provider.azure_devops_client.get_item.call_args_list + assert [call.kwargs["path"] for call in calls] == [ + "/new/name.py", + "/old/name.py", + "/new/name.py", + ] + assert [call.kwargs["version_descriptor"].version for call in calls] == [ + "head-sha", + "last-seen-sha", + "last-seen-sha", + ] + + @pytest.mark.parametrize("retry_status_code", [404, 500]) + def test_incremental_rename_uses_empty_base_when_both_checkpoint_paths_fail(self, retry_status_code): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="new\n"), + self._status_error(404), + self._status_error(retry_status_code), + ) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == "" + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == [ + "/new/name.py", + "/old/name.py", + "/new/name.py", + ] + assert any( + "/new/name.py" in message and "last-seen-sha" in message and "retry" in message + for message in captured + ) + + def test_incremental_rename_does_not_retry_after_successful_empty_old_path_read(self): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="new\n"), + SimpleNamespace(content=""), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.base_file == "" + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == [ + "/new/name.py", + "/old/name.py", + ] + + def test_incremental_rename_does_not_retry_current_path_after_non_404_failure(self): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="new\n"), + self._status_error(500), + ) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == "" + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == [ + "/new/name.py", + "/old/name.py", + ] + assert any( + "/old/name.py" in message and "last-seen-sha" in message and "500" in message + for message in captured + ) + def test_failed_rename_old_content_read_keeps_identity_and_logs_the_failure(self): change = self._change( path="/new/name.py", From 1885b979de737c68a2423180cd2cdb3fb5200991 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:51:15 +0545 Subject: [PATCH 170/238] fix: derive reasoning_effort support from litellm metadata (#3480) (#3485) --- docs/docs/usage-guide/changing_a_model.md | 6 +- .../usage-guide/configuration_reference.md | 4 +- pr_agent/algo/__init__.py | 24 ---- .../algo/ai_handlers/litellm_ai_handler.py | 110 +++++++++++++++--- pr_agent/settings/configuration.toml | 12 +- .../unittest/test_litellm_reasoning_effort.py | 81 ++++++++++++- tests/unittest/test_litellm_run_details.py | 2 +- 7 files changed, 182 insertions(+), 57 deletions(-) diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 32c7a2403e..18a5ac7fed 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -635,7 +635,7 @@ for the [Auto](https://openrouter.ai/docs/guides/routing/routers/auto-router), #### Openrouter provider routing, reasoning and output cap -For `openrouter/...` models you can optionally restrict which upstream providers Openrouter uses, control reasoning, and cap the completion length. All keys live in the `[openrouter]` section of `configuration.toml`. Models listed in [`SUPPORT_REASONING_EFFORT_MODELS`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py) inherit `config.reasoning_effort` unless an Openrouter-specific effort or token budget is set. +For `openrouter/...` models you can optionally restrict which upstream providers Openrouter uses, control reasoning, and cap the completion length. All keys live in the `[openrouter]` section of `configuration.toml`. Reasoning-capable models are those litellm's bundled reasoning metadata flags over the model id and its provider-prefixed/`xai/`-forms, the maintained Grok registry, or `config.additional_reasoning_effort_models`; they inherit `config.reasoning_effort` unless an Openrouter-specific effort or token budget is set. ```toml [openrouter] @@ -676,7 +676,7 @@ OPENAI__KEY=... (you can obtain an OrcaRouter API key from [here](https://www.orcarouter.ai/register)) -Keep the `openai/` prefix on the model name, whatever OrcaRouter model ID you use (`openai/anthropic/claude-fable-5`, `openai/auto`, ...): the prefix routes the request through litellm's OpenAI-compatible path. A prefixed name is not in the `MAX_TOKENS` table [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), so you also have to set `custom_model_max_tokens`. OrcaRouter governs routing and guardrails itself, but `config.reasoning_effort` still reaches it: PR-Agent matches the last segment of the model ID against `SUPPORT_REASONING_EFFORT_MODELS`, so an ID such as `openai/google/gemini-2.5-pro` or `openai/o3` sends the configured effort (default `"medium"`) even with nothing set. The example IDs above are not in that list and are unaffected. +Keep the `openai/` prefix on the model name, whatever OrcaRouter model ID you use (`openai/anthropic/claude-fable-5`, `openai/auto`, ...): the prefix routes the request through litellm's OpenAI-compatible path. A prefixed name is not in the `MAX_TOKENS` table [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), so you also have to set `custom_model_max_tokens`. OrcaRouter governs routing and guardrails itself, but `config.reasoning_effort` still reaches it: PR-Agent probes the suffixed model ID against litellm's bundled reasoning metadata (or `config.additional_reasoning_effort_models`), so an ID such as `openai/google/gemini-2.5-pro` or `openai/o3` sends the configured effort (default `"medium"`) even with nothing set. The example IDs above are not flagged as reasoning-capable and are unaffected. ### Neon AI Gateway @@ -795,7 +795,7 @@ reasoning_effort = "medium" # "none", "minimal", "low", "medium", "high", "xhigh With the OpenAI models that support reasoning effort (eg: gpt-5.6-terra), you can specify its reasoning effort via `config` section. The default value is `medium`. You can change it to any supported value based on your usage. Available values depend on the model and provider. -For a model served through an OpenAI-compatible endpoint that is not in the built-in [`SUPPORT_REASONING_EFFORT_MODELS`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py) list, add its ID to `config.additional_reasoning_effort_models`. The list is additive: built-in reasoning models keep receiving `config.reasoning_effort`, and IDs match exactly or through any provider prefix (e.g. `"deepseek-v4-flash-0731"` matches `"openai/deepseek-v4-flash-0731"`). When LiteLLM does not recognize the model, PR-Agent sets `allowed_openai_params = ["reasoning_effort"]` so the parameter reaches the endpoint. Note the default `"medium"` may be rejected by providers that accept a different subset (e.g. `"none"/"low"/"high"/"max"`); adding a custom model ID surfaces that provider-side error instead of silently dropping the setting. +For a model served through an OpenAI-compatible endpoint that litellm does not recognize as reasoning-capable, add its ID to `config.additional_reasoning_effort_models`. For known models support is decided by litellm's bundled reasoning metadata plus the maintained Grok registry (Grok ids resolve through their `xai/` prefix) with Claude models left out of the metadata path (their reasoning comes from the dedicated extended/adaptive thinking settings; an explicit entry in the list above still applies to them). Config IDs match exactly or through any provider prefix (e.g. `"deepseek-v4-flash-0731"` matches `"openai/deepseek-v4-flash-0731"`). When LiteLLM does not recognize the model, PR-Agent sets `allowed_openai_params = ["reasoning_effort"]` so the parameter reaches the endpoint. Note the default `"medium"` may be rejected by providers that accept a different subset (e.g. `"none"/"low"/"high"/"max"`); adding a custom model ID surfaces that provider-side error instead of silently dropping the setting. To use [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra): diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 9f4558e40b..0321c7027a 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -116,7 +116,7 @@ to-do list. | `enable_ai_metadata` | false | will enable adding ai metadata | | `add_user_to_requests` | false | send the current command and PR URL in the OpenAI-compatible "user" request field, for provider-side attribution of requests (e.g. OpenRouter "external_user") | | `reasoning_effort` | "medium" | "none", "minimal", "low", "medium", "high", "xhigh", "max" | -| `additional_reasoning_effort_models` | [] | Optional: additional model IDs that accept config.reasoning_effort. Appends to the built-in SUPPORT_REASONING_EFFORT_MODELS list (pr_agent/algo/__init__.py) instead of replacing it, so fallback_models mixing a custom endpoint model with a built-in reasoning model keep receiving the configured effort. Model IDs match exactly or through any provider prefix (e.g. "deepseek-v4-flash-0731" matches "openai/deepseek-v4-flash-0731"). LiteLLM whitelists reasoning_effort through allowed_openai_params for OpenAI-compatible models it does not recognize, so the parameter reaches the endpoint. The default "medium" may be rejected by providers that accept a different subset (e.g. "none"/"low"/"high"/"max"); adding a custom model id now surfaces a provider-side error instead of the previous silent drop. | +| `additional_reasoning_effort_models` | [] | Optional: additional model IDs that accept config.reasoning_effort. Reasoning support is otherwise decided by litellm's bundled model metadata (and the maintained Grok registry), so add an ID here only when litellm does not know the model (custom OpenAI-compatible endpoints). Model IDs match exactly or through any provider prefix (e.g. "deepseek-v4-flash-0731" matches "openai/deepseek-v4-flash-0731"). LiteLLM whitelists reasoning_effort through allowed_openai_params for OpenAI-compatible models it does not recognize, so the parameter reaches the endpoint. The default "medium" may be rejected by providers that accept a different subset (e.g. "none"/"low"/"high"/"max"); adding a custom model id now surfaces a provider-side error instead of the previous silent drop. | **extended thinking for Claude reasoning models** | Key | Default | Description | @@ -437,7 +437,7 @@ _This section only documents commented-out examples; see the [TOML source](https | `provider_only` | [] | restrict routing to these upstream providers only, a hard allowlist (e.g. ["z-ai"]); empty = OpenRouter default routing | | `provider_order` | [] | preferred provider order; ignored when provider_only is set; empty = unset | | `allow_fallbacks` | true | when provider_order is set, allow routing beyond the listed providers | -| `reasoning_effort` | "" | Invalid reasoning_effort values are warned about and treated as unset. Empty inherits config.reasoning_effort for models in SUPPORT_REASONING_EFFORT_MODELS. Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". OpenRouter normalizes "max" to "xhigh" for LiteLLM/OpenRouter compatibility. Model-specific support varies; mandatory reasoning models reject "none". | +| `reasoning_effort` | "" | Invalid reasoning_effort values are warned about and treated as unset. Empty inherits config.reasoning_effort for reasoning-capable models (probed against litellm's bundled reasoning metadata or the Grok registry, or listed in additional_reasoning_effort_models). Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". OpenRouter normalizes "max" to "xhigh" for LiteLLM/OpenRouter compatibility. Model-specific support varies; mandatory reasoning models reject "none". | | `reasoning_max_tokens` | 0 | A positive value overrides global effort and non-none OpenRouter-specific efforts. Explicit openrouter.reasoning_effort = "none" keeps reasoning disabled, except on Grok 4.5/4.6: there "none" is clamped to the lowest supported effort first, so a positive budget wins over it. Some providers require max_tokens to be greater than the reasoning budget. | | `max_tokens` | 0 | hard cap on completion tokens for the request; 0 = unset | diff --git a/pr_agent/algo/__init__.py b/pr_agent/algo/__init__.py index 74670fdd64..36c77e5c2b 100644 --- a/pr_agent/algo/__init__.py +++ b/pr_agent/algo/__init__.py @@ -461,30 +461,6 @@ def normalize_litellm_model(model: str, custom_llm_provider: str = "") -> str: *_claude_no_temp, ] -SUPPORT_REASONING_EFFORT_MODELS = [ - "o3-mini", - "o3-mini-2025-01-31", - "o3", - "o3-2025-04-16", - "o4-mini", - "o4-mini-2025-04-16", - # Gemini 2.5 exposes a thinking budget controlled by reasoning_effort. Without - # these entries a configured effort is silently dropped, so a runaway thinking - # trace can consume the whole output budget and return an empty completion. - # LiteLLM maps native provider paths to thinkingConfig.thinkingBudget, while - # LiteLLMAIHandler routes OpenRouter-prefixed forms through extra_body.reasoning. - "gemini-2.5-pro", - "gemini-2.5-flash", - # Register each published Grok id separately so provider-prefixed forms match - # and the allowlist below can clamp model-specific reasoning levels. - "grok-4.5", - "grok-4.5-latest", - "grok-build-latest", - "grok-4.6", - "grok-4.3", - "grok-4.3-latest", -] - # Clamp OpenAI-only levels for always-on Grok reasoning; allow xhigh on 4.6+. GROK_REASONING_EFFORT_LEVELS = { "grok-4.5": {"low", "medium", "high"}, diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 7c96e5c810..006db88220 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -48,7 +48,6 @@ GROK_REASONING_EFFORT_LEVELS, NO_SUPPORT_TEMPERATURE_MODELS, STREAMING_REQUIRED_MODELS, - SUPPORT_REASONING_EFFORT_MODELS, USER_MESSAGE_ONLY_MODELS, normalize_litellm_model, ) @@ -2193,14 +2192,16 @@ def __init__(self): # Model that doesn't support temperature argument self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS - # Append config-listed models to the built-in reasoning-effort list + # Config-listed models opt endpoints litellm does not know into receiving + # reasoning_effort. Reasoning support otherwise comes from litellm's own + # bundled model metadata (see _litellm_supports_reasoning). additional_reasoning_models = _coerce_string_list_config( get_settings().config.get("additional_reasoning_effort_models", []) ) if additional_reasoning_models is None: get_logger().warning( "Invalid additional_reasoning_effort_models in config; expected a list of model names. " - "Falling back to the built-in reasoning-effort model list." + "Ignoring it." ) additional_reasoning_models = [] elif additional_reasoning_models and not all( @@ -2209,12 +2210,12 @@ def __init__(self): get_logger().warning( "Invalid additional_reasoning_effort_models in config; " "expected a list of model name strings. " - "Falling back to the built-in reasoning-effort model list." + "Ignoring it." ) additional_reasoning_models = [] # Store stripped names so exact-match checks against the model succeed even when the # config entries contain surrounding whitespace (validation above already used strip()). - self.support_reasoning_models = SUPPORT_REASONING_EFFORT_MODELS + [ + self.additional_reasoning_effort_models = [ model.strip() for model in additional_reasoning_models ] @@ -3440,6 +3441,75 @@ def _has_priceable_usage(usage) -> bool: ) ) + @staticmethod + def _litellm_supports_reasoning(model: str) -> bool: + """Probe litellm's bundled model metadata for reasoning support. + + The metadata lookup is exact per spelling, so a model id only resolves when + the queried form is registered. The six Grok ids are registered only under + the ``xai/`` provider prefix (and are also guaranteed by the caller via the + GROK_REASONING_EFFORT_LEVELS registry, so they do not depend on map versions) + and bare o3/o4/Gemini ids resolve directly. To mirror the old + ``endswith("/")`` membership, probe every suffix of the id (after + stripping a routing suffix such as ``:nitro`` and the leading ``openrouter/`` + segment, whose provider-prefixed slugs can carry metadata for models this + handler never routed there) plus the ``xai/``-prefixed bare name. Claude + models are excluded by the caller via _is_claude_family_model: litellm maps + their reasoning_effort to a thinking token budget. + + The bundled cost map is consulted directly rather than via + ``litellm.supports_reasoning``: that public helper resolves the model through + ``get_llm_provider`` on every call, and provider resolution must stay out of + this gate because the api key guard snapshots the resolved provider and its + key. The map is also what #3475 pins through ``LITELLM_LOCAL_MODEL_COST_MAP``, + so the reads are deterministic for the model ids this gate handles. + """ + probe = model + if probe.startswith("openrouter/"): + probe = probe.removeprefix("openrouter/") + base = probe.rsplit(":", 1)[0] + segments = base.split("/") + candidates = [] + for i in range(len(segments)): + candidates.append("/".join(segments[i:])) + if i == len(segments) - 1: + candidates.append(f"xai/{segments[-1]}") + try: + return any( + LiteLLMAIHandler._model_cost_entry_supports_reasoning(candidate) + for candidate in candidates + ) + except Exception as e: + get_logger().warning( + f"Failed to probe litellm reasoning metadata for {model}: {e}" + ) + return False + + @staticmethod + def _model_cost_entry_supports_reasoning(model: str) -> bool: + """Return whether the bundled cost map flags one exact model id as reasoning-capable. + + Mirrors ``litellm.supports_reasoning``'s field check for an entry that exists: + only an explicit True counts. Missing entries and absent capability fields do + not enable the reasoning-effort path; the caller probes every candidate spelling + so a provider-prefixed model still resolves through its bare or ``xai/`` form. + """ + entry = litellm.model_cost.get(model) + return isinstance(entry, dict) and entry.get("supports_reasoning") is True + + @staticmethod + def _is_claude_family_model(model: str) -> bool: + """Recognize Claude model ids through any provider prefix. + + Claude reasoning is configured only through the dedicated extended/adaptive + thinking settings, so the generic reasoning_effort path must not pick these + models up even though litellm marks claude-sonnet-4-5 and claude-haiku-4-5 + as reasoning-capable: litellm maps reasoning_effort to a thinking token + budget there, silently enabling thinking for a model not opted in. + """ + normalized = model.lower().replace("_", "-").replace(".", "-") + return re.search(r"claude(?:-|$)", normalized) is not None + @staticmethod def _grok_reasoning_levels_for(model: str) -> set[str] | None: """Return the reasoning-effort levels accepted by a registered Grok model.""" @@ -4126,17 +4196,25 @@ async def _chat_completion_with_retry( kwargs.pop('temperature', None) reasoning_model = openrouter_model.rsplit(":", 1)[0] if openrouter_model else model - # Add reasoning_effort if model supports it. Match the bare model - # id as well as any provider-prefixed form (e.g. - # "openrouter/google/gemini-2.5-pro", "gemini/gemini-2.5-pro"), so a - # configured reasoning_effort is not silently dropped for models the - # user references with a provider prefix. OpenRouter routing variants - # such as :nitro and :floor are stripped only for this membership test. - # Skip GPT-5/GPT-6 Astra here so a model registered via config cannot - # overwrite the reasoning_effort normalization of its dedicated branch. - if not (is_gpt5_model or is_gpt6_astra) and any( - reasoning_model == m or reasoning_model.endswith("/" + m) - for m in self.support_reasoning_models + # Add reasoning_effort if the model supports it. Support comes from + # litellm's bundled model metadata (probed over suffix forms so bare, + # provider-prefixed, and OpenRouter :nitro/:floor variants all resolve), + # the GROK_REASONING_EFFORT_LEVELS registry (source of truth for the Grok + # ids, which older bundled maps do not all list), and + # config.additional_reasoning_effort_models as the operator escape hatch + # for endpoints litellm does not know. Claude models are excluded because + # their reasoning is driven by the dedicated + # enable_claude_extended/adaptive_thinking settings. Skip GPT-5/GPT-6 + # Astra here so a config-registered model cannot overwrite the + # reasoning_effort normalization of its dedicated branch. + if not (is_gpt5_model or is_gpt6_astra) and ( + self._grok_reasoning_levels_for(reasoning_model) is not None + or not self._is_claude_family_model(reasoning_model) + and self._litellm_supports_reasoning(reasoning_model) + or any( + reasoning_model == m or reasoning_model.endswith("/" + m) + for m in self.additional_reasoning_effort_models + ) ): config_effort = self._default_reasoning_effort reasoning_effort = self._resolve_reasoning_effort(openrouter_model or model, config_effort) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index b175217657..916387953d 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -98,10 +98,10 @@ propagate_tool_errors = false # when true, a tool re-raises instead of swallowin enable_ai_metadata = false # will enable adding ai metadata add_user_to_requests = false # send the current command and PR URL in the OpenAI-compatible "user" request field, for provider-side attribution of requests (e.g. OpenRouter "external_user") reasoning_effort = "medium" # "none", "minimal", "low", "medium", "high", "xhigh", "max" -# Optional: additional model IDs that accept config.reasoning_effort. Appends to the built-in -# SUPPORT_REASONING_EFFORT_MODELS list (pr_agent/algo/__init__.py) instead of replacing it, so -# fallback_models mixing a custom endpoint model with a built-in reasoning model keep receiving -# the configured effort. Model IDs match exactly or through any provider prefix +# Optional: additional model IDs that accept config.reasoning_effort. Reasoning support is +# otherwise decided by litellm's bundled model metadata (and the maintained Grok registry), +# so add an ID here only when litellm does not know the model (custom OpenAI-compatible +# endpoints). Model IDs match exactly or through any provider prefix # (e.g. "deepseek-v4-flash-0731" matches "openai/deepseek-v4-flash-0731"). LiteLLM whitelists # reasoning_effort through allowed_openai_params for OpenAI-compatible models it does not # recognize, so the parameter reaches the endpoint. The default "medium" may be rejected by @@ -452,7 +452,9 @@ provider_only = [] # restrict routing to these upstream providers only, a hard a provider_order = [] # preferred provider order; ignored when provider_only is set; empty = unset allow_fallbacks = true # when provider_order is set, allow routing beyond the listed providers # Invalid reasoning_effort values are warned about and treated as unset. -# Empty inherits config.reasoning_effort for models in SUPPORT_REASONING_EFFORT_MODELS. +# Empty inherits config.reasoning_effort for reasoning-capable models +# (probed against litellm's bundled reasoning metadata or the Grok registry, +# or listed in additional_reasoning_effort_models). # Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". # OpenRouter normalizes "max" to "xhigh" for LiteLLM/OpenRouter compatibility. # Model-specific support varies; mandatory reasoning models reject "none". diff --git a/tests/unittest/test_litellm_reasoning_effort.py b/tests/unittest/test_litellm_reasoning_effort.py index 7b88d66c23..2ae87bb368 100644 --- a/tests/unittest/test_litellm_reasoning_effort.py +++ b/tests/unittest/test_litellm_reasoning_effort.py @@ -48,6 +48,29 @@ def mock_logger(): yield mock_log_instance +@pytest.fixture(autouse=True) +def _pin_reasoning_support_metadata(monkeypatch): + """Pin the reasoning-support metadata this suite keys off. + + CI runs litellm 1.99.0 and 1.101.0 in parallel and their bundled cost maps + differ, so the regression matrix forces the entries the reasoning_effort gate + consults in ``litellm.model_cost``: bare o3/o4/Gemini-2.5 ids register + directly and claude-sonnet-4-5 / claude-haiku-4-5 report True while the + handler's claude-family check still keeps them out of the reasoning_effort + path. The six Grok ids are not pinned here: the gate recognizes them through + the GROK_REASONING_EFFORT_LEVELS registry, so their coverage does not depend + on the bundled map (xai/grok-build-latest is absent from the 1.99.0 map). + All other bundled entries stay untouched. + """ + reasoning_models = ( + "o3-mini", "o3-mini-2025-01-31", "o3", "o3-2025-04-16", + "o4-mini", "o4-mini-2025-04-16", "gemini-2.5-pro", "gemini-2.5-flash", + "claude-sonnet-4-5", "claude-haiku-4-5", + ) + for model in reasoning_models: + monkeypatch.setitem(litellm.model_cost, model, {"supports_reasoning": True}) + + class TestLiteLLMReasoningEffort: """ Comprehensive test suite for GPT-5 reasoning_effort configuration handling. @@ -985,10 +1008,10 @@ async def test_astra_output_limit(self, monkeypatch): class TestLiteLLMReasoningEffortGemini: - """Gemini 2.5 reasoning_effort handling via the SUPPORT_REASONING_EFFORT_MODELS path. + """Gemini 2.5 reasoning_effort handling via litellm's bundled metadata. Gemini 2.5 exposes a thinking budget that LiteLLM maps from reasoning_effort. The - membership test in chat_completion matches bare and provider-prefixed ids such as + support probe in chat_completion matches bare and provider-prefixed ids such as "vertex_ai/gemini-2.5-pro". OpenRouter models use extra_body.reasoning instead and are covered by test_litellm_openrouter_controls.py. """ @@ -1059,6 +1082,52 @@ async def test_suffix_match_does_not_overmatch(self, monkeypatch, mock_logger): call_kwargs = mock_completion.call_args[1] assert "reasoning_effort" not in call_kwargs + @pytest.mark.asyncio + async def test_claude_models_excluded_from_reasoning_effort_path(self, monkeypatch, mock_logger): + """Keep reasoning_effort off Claude models even though litellm metadata marks + claude-sonnet-4-5 / claude-haiku-4-5 as reasoning-capable; pr-agent routes + Claude reasoning only through the dedicated extended/adaptive thinking settings. + """ + fake_settings = create_mock_settings("high") + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + self._isolate_env(monkeypatch) + + for model in ("claude-sonnet-4-5", "anthropic/claude-sonnet-4-5"): + with patch( + 'pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion', + new_callable=AsyncMock, + ) as mock_completion: + mock_completion.return_value = create_mock_acompletion_response() + + handler = LiteLLMAIHandler() + await handler.chat_completion(model=model, system="test system", user="test user") + + call_kwargs = mock_completion.call_args[1] + assert "reasoning_effort" not in call_kwargs, f"reasoning_effort leaked for {model}" + + @pytest.mark.asyncio + async def test_metadata_only_model_gets_reasoning_effort(self, monkeypatch, mock_logger): + """Forward reasoning_effort to a model only litellm's bundled metadata flags. + o1 was never in the removed SUPPORT_REASONING_EFFORT_MODELS list and is not + pinned by the autouse fixture, so this goes red if the metadata probe stops + being consulted. + """ + fake_settings = create_mock_settings("low") + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + self._isolate_env(monkeypatch) + for model in ("o1", "openai/o1"): + with patch( + 'pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion', + new_callable=AsyncMock, + ) as mock_completion: + mock_completion.return_value = create_mock_acompletion_response() + + handler = LiteLLMAIHandler() + await handler.chat_completion(model=model, system="test system", user="test user") + + call_kwargs = mock_completion.call_args[1] + assert call_kwargs.get("reasoning_effort") == "low", f"reasoning_effort dropped for {model}" + class TestLiteLLMReasoningEffortGrok: """Cover Grok-specific reasoning levels without duplicating generic OpenRouter tests.""" @@ -1305,8 +1374,8 @@ class TestAdditionalReasoningEffortModels: """Verify config.additional_reasoning_effort_models opts custom OpenAI-compatible model IDs into config.reasoning_effort. - Keep the override additive so built-in SUPPORT_REASONING_EFFORT_MODELS entries stay - active and a fallback_models chain mixing a custom endpoint with a built-in reasoning + Keep the override additive so litellm's bundled reasoning metadata stays + active and a fallback_models chain mixing a custom endpoint with a reasoning model keeps receiving the configured effort. When LiteLLM does not recognize the model, whitelist reasoning_effort through allowed_openai_params so the endpoint receives it. """ @@ -1387,8 +1456,8 @@ async def test_override_is_additive(self, monkeypatch, mock_logger): ["deepseek-v4-flash-0731", 123], [""], ]) - async def test_invalid_override_falls_back_to_builtin_list(self, monkeypatch, mock_logger, additional): - """Reject an unsupported override with a warning and fall back to the built-in list.""" + async def test_invalid_override_ignored(self, monkeypatch, mock_logger, additional): + """Reject an unsupported override with a warning; the model just gets no effort.""" fake_settings = self._settings(additional=additional) monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) monkeypatch.setattr(litellm, "get_supported_openai_params", lambda **kwargs: []) diff --git a/tests/unittest/test_litellm_run_details.py b/tests/unittest/test_litellm_run_details.py index af03720d89..3d2c38dbe6 100644 --- a/tests/unittest/test_litellm_run_details.py +++ b/tests/unittest/test_litellm_run_details.py @@ -189,7 +189,7 @@ def _bare_handler(): handler.add_litellm_callbacks = False handler.claude_extended_thinking_models = [] handler.no_support_temperature_models = [] - handler.support_reasoning_models = [] + handler.additional_reasoning_effort_models = [] handler.user_message_only_models = [] handler._claude_thinking_controls = { "enable_claude_adaptive_thinking": False, From 275d1386e943163e924dd2265f7da55e14da909e Mon Sep 17 00:00:00 2001 From: Monank Patel Date: Sat, 19 Sep 2026 13:21:05 +0530 Subject: [PATCH 171/238] docs: remove dead Google Tag Manager snippets (#3428) Co-authored-by: deadpool-mjpatel4444 <311860525+deadpool-mjpatel4444@users.noreply.github.com> Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- docs/overrides/main.html | 9 --------- .../partials/integrations/analytics/custom.html | 7 ------- 2 files changed, 16 deletions(-) delete mode 100644 docs/overrides/partials/integrations/analytics/custom.html diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 7d729c5aab..43eb6b3525 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -3,12 +3,3 @@ {% block announce %} Open source PR Agent documentation. For the Qodo free version for open-source projects, visit: https://www.qodo.ai/solutions/open-source/ {% endblock %} - -{% block scripts %} - {{ super() }} - - - - -{% endblock %} diff --git a/docs/overrides/partials/integrations/analytics/custom.html b/docs/overrides/partials/integrations/analytics/custom.html deleted file mode 100644 index 63dc6503aa..0000000000 --- a/docs/overrides/partials/integrations/analytics/custom.html +++ /dev/null @@ -1,7 +0,0 @@ - - - From 74e05beab391a0a24571cfac725e55973b0cd4ec Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Sat, 19 Sep 2026 08:03:09 -0500 Subject: [PATCH 172/238] fix: prevent changelog overwrite after read failures (#3495) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- docs/docs/tools/update_changelog.md | 2 +- pr_agent/git_providers/github_provider.py | 10 +- pr_agent/git_providers/gitlab_provider.py | 10 +- pr_agent/tools/pr_update_changelog.py | 114 ++++++-- tests/unittest/test_bitbucket_provider.py | 17 ++ .../test_github_provider_file_content.py | 49 ++++ tests/unittest/test_gitlab_provider.py | 28 ++ tests/unittest/test_pr_update_changelog.py | 272 ++++++++++++++++++ 8 files changed, 467 insertions(+), 35 deletions(-) create mode 100644 tests/unittest/test_github_provider_file_content.py diff --git a/docs/docs/tools/update_changelog.md b/docs/docs/tools/update_changelog.md index 53b8b0399d..13c74e6048 100644 --- a/docs/docs/tools/update_changelog.md +++ b/docs/docs/tools/update_changelog.md @@ -17,7 +17,7 @@ It can be invoked manually by commenting on any PR: Under the section `pr_update_changelog`, the [configuration file](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) contains options to customize the 'update changelog' tool: -- `push_changelog_changes`: whether to push the changes to CHANGELOG.md, or just publish them as a comment. Default is false (publish as comment). +- `push_changelog_changes`: whether to push the changes to CHANGELOG.md, or just publish them as a comment. Default is false (publish as comment). Before pushing, the tool requires a confirmed read of the existing CHANGELOG.md; a confirmed missing file is treated as empty, while other read failures skip the repository write, attempt to publish a not-pushed fallback comment, and surface the original error. - `extra_instructions`: Optional extra instructions to the tool. For example: "Use the following structure: ..." - `add_pr_link`: whether the model should try to add a link to the PR in the changelog. Default is true. - `skip_ci_on_push`: whether the commit message (when `push_changelog_changes` is true) will include the term "[skip ci]", preventing CI tests to be triggered on the changelog commit. Default is true. diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index c36014b53d..512ef236d4 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1661,14 +1661,22 @@ def _get_repo(self): def _get_pr(self): return self._get_repo().get_pull(self.pr_num) - def get_pr_file_content(self, file_path: str, branch: str) -> str: + def get_pr_file_content(self, file_path: str, branch: str, propagate_errors: bool = False) -> str: try: file_content_str = str( self._get_repo() .get_contents(file_path, ref=branch) .decoded_content.decode() ) + except GithubException as e: + if e.status == 404: + return "" + if propagate_errors: + raise + file_content_str = "" except Exception: + if propagate_errors: + raise file_content_str = "" return file_content_str diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 5df461bada..ecea35b5a7 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -890,16 +890,18 @@ def _get_own_user_id(self) -> Optional[int]: self._own_user_id = None return self._own_user_id - def get_pr_file_content(self, file_path: str, branch: str) -> str: + def get_pr_file_content(self, file_path: str, branch: str, propagate_errors: bool = False) -> str: try: file_obj = self.gl.projects.get(self.id_project, lazy=True).files.get(file_path, branch) content = file_obj.decode() return decode_if_bytes(content) - except GitlabGetError: - # In case of file creation the method returns GitlabGetError (404 file not found). - # In this case we return an empty string for the diff. + except GitlabGetError as e: + if propagate_errors and getattr(e, "response_code", None) != 404: + raise return '' except Exception as e: + if propagate_errors: + raise get_logger().warning(f"Error retrieving file {file_path} from branch {branch}: {e}") return '' diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index fef094e832..db3e740c64 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -62,34 +62,46 @@ def __init__(self, pr_url: str, cli_mode=False, args=None, ai_handler: partial[B self.main_language = get_main_pr_language( self.git_provider.get_languages(), self.git_provider.get_files() ) + self.changelog_read_error = None self._get_changelog_file() # self.changelog_file_str - self.ai_handler = ai_handler() - if self.main_language: - self.ai_handler.main_pr_language = self.main_language - - self.patches_diff = None - self.prediction = None - self.cli_mode = cli_mode - self.vars = { - "title": self.git_provider.pr.title, - "branch": self.git_provider.get_pr_branch(), - "description": self.git_provider.get_pr_description(), - "language": self.main_language, - "diff": "", # empty diff for initial calculation - "pr_link": "", - "changelog_file_str": self.changelog_file_str, - "today": date.today(), - "extra_instructions": get_settings().pr_update_changelog.extra_instructions, - "commit_messages_str": self.git_provider.get_commit_messages(), - } - self.token_handler = TokenHandler(self.git_provider.pr, - self.vars, - get_settings().pr_update_changelog_prompt.system, - get_settings().pr_update_changelog_prompt.user) + try: + self.ai_handler = ai_handler() + if self.main_language: + self.ai_handler.main_pr_language = self.main_language + + self.patches_diff = None + self.prediction = None + self.cli_mode = cli_mode + self.vars = { + "title": self.git_provider.pr.title, + "branch": self.git_provider.get_pr_branch(), + "description": self.git_provider.get_pr_description(), + "language": self.main_language, + "diff": "", # empty diff for initial calculation + "pr_link": "", + "changelog_file_str": self.changelog_file_str, + "today": date.today(), + "extra_instructions": get_settings().pr_update_changelog.extra_instructions, + "commit_messages_str": self.git_provider.get_commit_messages(), + } + self.token_handler = TokenHandler(self.git_provider.pr, + self.vars, + get_settings().pr_update_changelog_prompt.system, + get_settings().pr_update_changelog_prompt.user) + except Exception as setup_error: + changelog_read_error = self.changelog_read_error + if changelog_read_error is None: + raise + get_logger().exception( + f"Failed to initialize changelog generation after a read error: {setup_error}" + ) + self._publish_changelog_read_error_fallback() + raise changelog_read_error async def run(self): get_logger().info('Updating the changelog...') + changelog_read_error = getattr(self, "changelog_read_error", None) # If a push was requested but isn't possible (unsupported provider or restricted_mode), # the changelog is still generated and published as a comment below (commit_changelog is @@ -102,14 +114,34 @@ async def run(self): temporary_comment_published = False if get_settings().config.publish_output: - self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True) - temporary_comment_published = True + try: + self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True) + temporary_comment_published = True + except Exception as progress_error: + if changelog_read_error is None: + raise + get_logger().exception( + f"Failed to publish changelog progress after a read error: {progress_error}" + ) try: - await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.WEAK) + try: + await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.WEAK) + except Exception as generation_error: + if changelog_read_error is None: + raise + get_logger().exception( + f"Failed to generate changelog fallback after a read error: {generation_error}" + ) + self._publish_changelog_read_error_fallback() + raise changelog_read_error new_file_content, answer = self._prepare_changelog_update() + if changelog_read_error is not None: + self._publish_changelog_read_error_fallback(answer) + raise changelog_read_error + # Output the relevant configurations if enabled if get_settings().get('config', {}).get('output_relevant_configurations', False): answer += show_relevant_configurations(relevant_section='pr_update_changelog') @@ -136,6 +168,22 @@ async def run(self): f"Failed to remove the temporary changelog comment: {cleanup_error}" ) + def _publish_changelog_read_error_fallback(self, answer: str = ""): + if answer: + changelog_comment = f"**Changelog updates:** 🔄\n\n{answer}" + else: + changelog_comment = "**Changelog update could not be generated.**" + changelog_comment += ( + "\n\n> ⚠️ These changes were not pushed because the existing " + "CHANGELOG.md could not be read safely." + ) + try: + self.git_provider.publish_comment(changelog_comment) + except Exception as fallback_error: + get_logger().exception( + f"Failed to publish changelog fallback after a read error: {fallback_error}" + ) + async def _prepare_prediction(self, model: str): variables = copy.deepcopy(self.vars) if get_settings().pr_update_changelog.add_pr_link: @@ -259,10 +307,16 @@ def _get_default_changelog(self): return example_changelog def _get_changelog_file(self): + strict_read = self.commit_changelog and get_settings().config.publish_output try: - self.changelog_file = self.git_provider.get_pr_file_content( - "CHANGELOG.md", self.git_provider.get_pr_branch() - ) + if strict_read: + self.changelog_file = self.git_provider.get_pr_file_content( + "CHANGELOG.md", self.git_provider.get_pr_branch(), propagate_errors=True + ) + else: + self.changelog_file = self.git_provider.get_pr_file_content( + "CHANGELOG.md", self.git_provider.get_pr_branch() + ) if isinstance(self.changelog_file, bytes): self.changelog_file = self.changelog_file.decode('utf-8') @@ -272,6 +326,8 @@ def _get_changelog_file(self): self.changelog_file_str = "\n".join(changelog_file_lines) except Exception as e: get_logger().warning(f"Error getting changelog file: {e}") + if strict_read: + self.changelog_read_error = e self.changelog_file_str = "" self.changelog_file = "" return diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index 29fcdbc4b2..41e2d493eb 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -412,6 +412,23 @@ def test_get_pr_file_content_propagates_non_404_http_errors_by_default(self): response.raise_for_status.assert_called_once_with() + def test_get_pr_file_content_strict_missing_file_is_empty(self): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.workspace_slug = "workspace" + provider.repo_slug = "repository" + provider.headers = {"Authorization": "Bearer token"} + provider.pr = MagicMock( + source_branch="feature", + destination_branch="main", + data={"destination": {"commit": {"hash": "base-sha"}}}, + ) + response = MagicMock(status_code=404, text="not found") + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response): + assert provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) == "" + + response.raise_for_status.assert_not_called() + @pytest.mark.parametrize("comment", [{"id": 123}, 123]) def test_remove_comment_accepts_returned_comment_or_stored_id(self, comment): provider = BitbucketProvider.__new__(BitbucketProvider) diff --git a/tests/unittest/test_github_provider_file_content.py b/tests/unittest/test_github_provider_file_content.py new file mode 100644 index 0000000000..1007072761 --- /dev/null +++ b/tests/unittest/test_github_provider_file_content.py @@ -0,0 +1,49 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from github import GithubException + +from pr_agent.git_providers.github_provider import GithubProvider + + +def _provider_with_result(result=None, error=None): + provider = GithubProvider.__new__(GithubProvider) + repo = MagicMock() + if error is not None: + repo.get_contents.side_effect = error + else: + repo.get_contents.return_value = SimpleNamespace(decoded_content=result) + provider._get_repo = MagicMock(return_value=repo) + return provider + + +def test_get_pr_file_content_keeps_default_leniency(): + provider_error = GithubException(500, {"message": "upstream failure"}, {}) + provider = _provider_with_result(error=provider_error) + + assert provider.get_pr_file_content("CHANGELOG.md", "main") == "" + + +def test_get_pr_file_content_strict_missing_file_is_empty(): + not_found = GithubException(404, {"message": "Not Found"}, {}) + provider = _provider_with_result(error=not_found) + + assert provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) == "" + + +@pytest.mark.parametrize( + "error", + [ + GithubException(500, {"message": "upstream failure"}, {}), + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), + RuntimeError("transport failed"), + ], +) +def test_get_pr_file_content_strict_reraises_original_error(error): + provider = _provider_with_result(error=error) + + with pytest.raises(type(error)) as exc_info: + provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) + + assert exc_info.value is error diff --git a/tests/unittest/test_gitlab_provider.py b/tests/unittest/test_gitlab_provider.py index 7a24d6290f..796c3eaffb 100644 --- a/tests/unittest/test_gitlab_provider.py +++ b/tests/unittest/test_gitlab_provider.py @@ -134,6 +134,34 @@ def test_get_pr_file_content_other_exception(self, gitlab_provider, mock_project assert content == "" + def test_get_pr_file_content_strict_missing_file_is_empty(self, gitlab_provider, mock_project): + mock_project.files.get.side_effect = GitlabGetError("404 Not Found", response_code=404) + + content = gitlab_provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) + + assert content == "" + + @pytest.mark.parametrize( + "error", + [ + GitlabGetError("500 Server Error", response_code=500, response_body=b"upstream failure"), + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), + RuntimeError("transport failed"), + ], + ) + def test_get_pr_file_content_strict_reraises_original_error(self, gitlab_provider, mock_project, error): + mock_file = MagicMock(ProjectFile) + if isinstance(error, UnicodeDecodeError): + mock_file.decode.side_effect = error + mock_project.files.get.return_value = mock_file + else: + mock_project.files.get.side_effect = error + + with pytest.raises(type(error)) as exc_info: + gitlab_provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) + + assert exc_info.value is error + def test_get_repo_file_content_loads_from_mr_target_branch(self, gitlab_provider, mock_gitlab_client, mock_project): mock_project.default_branch = "main" gitlab_provider.mr = MagicMock(target_branch="release-1.0") diff --git a/tests/unittest/test_pr_update_changelog.py b/tests/unittest/test_pr_update_changelog.py index 42de474a8e..47bc92a7e2 100644 --- a/tests/unittest/test_pr_update_changelog.py +++ b/tests/unittest/test_pr_update_changelog.py @@ -164,6 +164,278 @@ def _make_no_push_provider(self, extra_spec=None): provider.supports_changelog_update_review.return_value = False return provider + def _make_push_provider(self): + provider = self._make_no_push_provider(extra_spec=["create_or_update_pr_file"]) + provider.is_supported.return_value = True + return provider + + @staticmethod + def _configure_settings(mock_settings, publish_output=True): + settings = mock_settings.return_value + settings.pr_update_changelog.push_changelog_changes = True + settings.pr_update_changelog.extra_instructions = "" + settings.pr_update_changelog_prompt.system = "" + settings.pr_update_changelog_prompt.user = "" + settings.config.publish_output = publish_output + settings.config.temperature = 0.2 + settings.get.return_value = {} + + @pytest.mark.asyncio + async def test_strict_read_error_generates_one_fallback_never_writes_and_reraises_original( + self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + provider.get_pr_file_content.side_effect = read_error + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models") as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + provider.get_pr_file_content.assert_called_once_with( + "CHANGELOG.md", "feature-branch", propagate_errors=True + ) + provider.create_or_update_pr_file.assert_not_called() + assert retry.await_count == 1 + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "Safe generated entry" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_called_once_with() + + @pytest.mark.asyncio + async def test_strict_read_error_skips_configuration_rendering(self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + provider.get_pr_file_content.side_effect = read_error + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), \ + patch("pr_agent.tools.pr_update_changelog.show_relevant_configurations", + side_effect=TypeError("invalid skip_keys")) as render_config, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + mock_settings.return_value.get.return_value = {"output_relevant_configurations": True} + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + render_config.assert_not_called() + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "Safe generated entry" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_called_once_with() + + def test_strict_read_setup_failure_attempts_fallback_and_reraises_original(self): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + setup_error = RuntimeError("handler unavailable") + provider.get_pr_file_content.side_effect = read_error + handler_factory = MagicMock(side_effect=setup_error) + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + + with pytest.raises(RuntimeError) as exc_info: + PRUpdateChangelog("https://example.com/pr/1", ai_handler=handler_factory) + + assert exc_info.value is read_error + handler_factory.assert_called_once_with() + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "could not be generated" in fallback_calls[0].args[0] + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to initialize changelog generation after a read error: handler unavailable" + ) + + @pytest.mark.asyncio + async def test_strict_read_fallback_failure_does_not_mask_original(self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + fallback_error = RuntimeError("comment unavailable") + provider.get_pr_file_content.side_effect = read_error + + def publish_comment(_body, is_temporary=False): + if not is_temporary: + raise fallback_error + + provider.publish_comment.side_effect = publish_comment + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + provider.create_or_update_pr_file.assert_not_called() + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to publish changelog fallback after a read error: comment unavailable" + ) + + @pytest.mark.asyncio + async def test_strict_read_progress_failure_still_generates_fallback_and_reraises_original( + self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + progress_error = RuntimeError("progress unavailable") + provider.get_pr_file_content.side_effect = read_error + + def publish_comment(_body, is_temporary=False): + if is_temporary: + raise progress_error + + provider.publish_comment.side_effect = publish_comment + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models") as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + assert retry.await_count == 1 + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "Safe generated entry" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_not_called() + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to publish changelog progress after a read error: progress unavailable" + ) + + @pytest.mark.asyncio + async def test_strict_read_generation_failure_attempts_fallback_and_reraises_original( + self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + generation_error = RuntimeError("generation unavailable") + provider.get_pr_file_content.side_effect = read_error + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models", + side_effect=generation_error) as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + assert retry.await_count == 1 + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "could not be generated" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_called_once_with() + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to generate changelog fallback after a read error: generation unavailable" + ) + + def test_custom_provider_without_strict_keyword_fails_closed_without_retry(self, mock_ai_handler): + provider = self._make_push_provider() + + def legacy_getter(_file_path, _branch): + return "existing content" + + provider.get_pr_file_content.side_effect = legacy_getter + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + + assert isinstance(tool.changelog_read_error, TypeError) + assert provider.get_pr_file_content.call_count == 1 + provider.get_pr_file_content.assert_called_once_with( + "CHANGELOG.md", "feature-branch", propagate_errors=True + ) + + @pytest.mark.parametrize("content", ["", "# Changelog\n\n## v1.0.0\n- Existing entry"]) + def test_strict_read_accepts_successful_empty_and_nonempty_content(self, mock_ai_handler, content): + provider = self._make_push_provider() + provider.get_pr_file_content.return_value = content + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + + assert tool.changelog_read_error is None + assert tool.changelog_file == content + provider.get_pr_file_content.assert_called_once_with( + "CHANGELOG.md", "feature-branch", propagate_errors=True + ) + + @pytest.mark.asyncio + async def test_output_disabled_keeps_lenient_read_and_never_writes(self, mock_ai_handler): + provider = self._make_push_provider() + provider.get_pr_file_content.side_effect = RuntimeError("read unavailable") + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models") as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings, publish_output=False) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + await tool.run() + + assert tool.changelog_read_error is None + assert retry.await_count == 1 + provider.get_pr_file_content.assert_called_once_with("CHANGELOG.md", "feature-branch") + provider.create_or_update_pr_file.assert_not_called() + provider.publish_comment.assert_not_called() + provider.remove_initial_comment.assert_not_called() + @pytest.mark.asyncio async def test_run_without_push_support(self, mock_ai_handler): """When the provider can't push (no create_or_update_pr_file), the changelog must still From 909dcd68bdbf557736c34df1261608cc6e08e983 Mon Sep 17 00:00:00 2001 From: noooooooookro <1506769455@qq.com> Date: Sat, 19 Sep 2026 21:11:10 +0800 Subject: [PATCH 173/238] fix(suggestions): allow required imports in improvements (#3169) Co-authored-by: Ismael Martinez Ramos --- .../pr_code_suggestions_prompts.toml | 3 +- ...ode_suggestions_prompts_not_decoupled.toml | 5 ++-- .../pr_code_suggestions_reflect_prompts.toml | 2 +- tests/unittest/test_prompt_fragments.py | 30 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts.toml b/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts.toml index e23149bd03..bd840f23a0 100644 --- a/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts.toml +++ b/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts.toml @@ -25,11 +25,12 @@ Specific guidelines for generating code suggestions: - Only give suggestions that address critical problems and bugs in the PR code. If no relevant suggestions are applicable, return an empty list. - DO NOT suggest the following: - change packages version - - add missing import statement + - add standalone or unrelated missing import statements - declare undefined variable, or remove unused variable - use more specific exception types - repeat changes already done in the PR code {%- endif %} +- If the suggested `improved_code` introduces a dependency, include the import needed to make that suggestion valid. Do not suggest unrelated or standalone missing imports. - Be aware that your input consists only of partial code segments (PR diff code), not the complete codebase. Therefore, avoid making suggestions that might duplicate existing functionality, and refrain from questioning code elements (such as variable declarations or import statements) that may be defined elsewhere in the codebase. - When mentioning code elements (variables, names, or files) in your response, surround them with backticks (`). For example: "verify that `user_id` is..." diff --git a/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml b/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml index 8e6cc32639..36a5b38580 100644 --- a/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml +++ b/pr_agent/settings/code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml @@ -54,10 +54,11 @@ Specific guidelines for generating code suggestions: - Only give suggestions that address critical problems and bugs in the PR code. If no relevant suggestions are applicable, return an empty list. - DO NOT suggest the following: - change packages version - - add missing import statement - - declare undefined variable, add missing imports, etc. + - add standalone or unrelated missing imports + - declare undefined variable, or remove unused variable - use more specific exception types {%- endif %} +- If the suggested `improved_code` introduces a dependency, include the import needed to make that suggestion valid. Do not suggest unrelated or standalone missing imports. - When mentioning code elements (variables, names, or files) in your response, surround them with markdown backticks (`). For example: "verify that `user_id` is..." - Note that you will only see partial code segments that were changed (diff hunks in a PR code), and not the entire codebase. Avoid suggestions that might duplicate existing functionality of the outer codebase. In addition, the absence of a definition, declaration, import, or initialization for any entity in the PR code is NEVER a basis for a suggestion. - Also note that if the code ends at an opening brace or statement that begins a new scope (like 'if', 'for', 'try'), don't treat it as incomplete. Instead, acknowledge the visible scope boundary and analyze only the code shown. diff --git a/pr_agent/settings/code_suggestions/pr_code_suggestions_reflect_prompts.toml b/pr_agent/settings/code_suggestions/pr_code_suggestions_reflect_prompts.toml index 9bc405f92c..11437531c9 100644 --- a/pr_agent/settings/code_suggestions/pr_code_suggestions_reflect_prompts.toml +++ b/pr_agent/settings/code_suggestions/pr_code_suggestions_reflect_prompts.toml @@ -36,7 +36,7 @@ Additional scoring considerations: - Assign a score of 0 to suggestions aiming at: - Adding docstring, type hints, or comments - Remove unused imports or variables - - Add missing import statements + - Add standalone or unrelated missing import statements - Using more specific exception types. - Questions the definition, declaration, import, or initialization of any entity in the PR code, that might be done in the outer codebase. diff --git a/tests/unittest/test_prompt_fragments.py b/tests/unittest/test_prompt_fragments.py index 004277340b..cfa51351ba 100644 --- a/tests/unittest/test_prompt_fragments.py +++ b/tests/unittest/test_prompt_fragments.py @@ -106,6 +106,36 @@ def test_affected_templates_delegate_to_shared_fragment_once(prompt_name, legacy assert legacy_example not in system_prompt +@pytest.mark.parametrize("prompt_name", [ + "pr_code_suggestions_prompt", + "pr_code_suggestions_prompt_not_decoupled", +]) +def test_suggestion_prompts_allow_dependencies_introduced_by_improved_code(prompt_name): + prompt = get_settings().get(prompt_name).system + variables = { + "focus_only_on_problems": True, + "num_code_suggestions": 3, + "diff_hunk_format": "diff", + "skills_context": "", + "extra_instructions": "", + "repo_context": "", + } + + rendered = Environment().from_string(prompt).render(variables) + + assert "If the suggested `improved_code` introduces a dependency" in rendered + assert "standalone or unrelated missing" in rendered + + +def test_reflection_prompt_does_not_zero_required_imports(): + prompt = get_settings().pr_code_suggestions_reflect_prompt.system + + rendered = Environment().from_string(prompt).render({"diff_hunk_format": "diff"}) + + assert "- Add standalone or unrelated missing import statements" in rendered + assert "- Add missing import statements" not in rendered + + def test_fragment_renderer_sandboxes_host_overrides(restore_prompt_settings): get_settings().set( "prompt_fragments.diff_hunk_format", From 2b592e4be1dde9856f5976dfeba12f40e6362789 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Sat, 19 Sep 2026 16:45:20 +0330 Subject: [PATCH 174/238] feat(status): acknowledge a pull request the moment automatic commands start (#3091) Co-authored-by: Ismael Martinez Ramos --- .../docs/usage-guide/automations_and_usage.md | 2 +- pr_agent/algo/run_details.py | 18 + pr_agent/git_providers/github_provider.py | 68 ++- pr_agent/servers/github_app.py | 68 ++- pr_agent/tools/pr_code_suggestions.py | 4 +- pr_agent/tools/pr_description.py | 4 +- pr_agent/tools/pr_reviewer.py | 4 +- .../test_github_provider_check_runs.py | 1 + .../test_run_status_acknowledgment.py | 390 ++++++++++++++++++ 9 files changed, 537 insertions(+), 22 deletions(-) create mode 100644 tests/unittest/test_run_status_acknowledgment.py diff --git a/docs/docs/usage-guide/automations_and_usage.md b/docs/docs/usage-guide/automations_and_usage.md index d674f857f5..09618a7ec7 100644 --- a/docs/docs/usage-guide/automations_and_usage.md +++ b/docs/docs/usage-guide/automations_and_usage.md @@ -244,7 +244,7 @@ env: Review result is output as JSON to `steps.{step-id}.outputs.review` property. The JSON structure is equivalent to the yaml data structure defined in [pr_reviewer_prompts.toml](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml). -`github.publish_as_check_run` controls whether tool output (review, describe, improve) is published as a GitHub Check Run instead of a PR comment (default is `false`). When enabled, results appear in the "Checks" tab of the PR. Requires `checks: write` permission in the workflow YAML. +`github.publish_as_check_run` controls whether tool output (review, describe, improve) is published as a GitHub Check Run instead of a PR comment (default is `false`). When enabled, results appear in the "Checks" tab of the PR. Requires `checks: write` permission in the workflow YAML. On the GitHub App, each automatic command opens its check run as in progress before the tool runs, so the author sees that PR-Agent picked the pull request up before any output exists; the run is completed with the tool's output, or marked failed if the command did not finish. Note that you can give additional config parameters by adding environment variables to `.github/workflows/pr_agent.yml`, or by using a `.pr_agent.toml` [configuration file](./configuration_options.md#global-configuration-file) in the root of your repo diff --git a/pr_agent/algo/run_details.py b/pr_agent/algo/run_details.py index 0d31951272..7324fe53ac 100644 --- a/pr_agent/algo/run_details.py +++ b/pr_agent/algo/run_details.py @@ -57,6 +57,10 @@ class RunDetails: # Monotonic reference taken when the collector is installed, i.e. at the top of the # tool's run(). Monotonic so that wall-clock adjustments cannot yield a negative duration. start_time: float = field(default_factory=time.monotonic) + # Set when a tool caught its own error and, because `propagate_tool_errors` is false by + # default, returned normally anyway. Without this a caller cannot tell a run that published + # nothing because it failed from one that had nothing to say. + command_failed: bool = False @property def duration_seconds(self) -> float: @@ -92,6 +96,20 @@ def get_run_details() -> Optional[RunDetails]: return _run_details.get() +def record_command_failure() -> None: + """Mark the current run as failed, for a tool that is about to swallow its own error.""" + details = get_run_details() + if details is None: + return + details.command_failed = True + + +def command_failed() -> bool: + """Whether the current run recorded a swallowed failure.""" + details = get_run_details() + return bool(details is not None and details.command_failed) + + def record_model_used(model: str, is_fallback: bool) -> None: """Record the model that produced a successful completion.""" details = get_run_details() diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 512ef236d4..adb61a714d 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -85,6 +85,7 @@ def __init__(self, pr_url: Optional[str] = None): self.incremental = IncrementalPR(False) self._resolved_config_branch: str | None = None self._check_run_ids: dict = {} + self._check_runs_in_progress: set = set() if pr_url and 'pull' in pr_url: self.set_pr(pr_url) self.pr_commits = list(self.pr.get_commits()) @@ -621,38 +622,73 @@ def is_comment_authored_by_pr_agent(self, comment) -> bool: raise RuntimeError("GitHub identity cannot be verified") return login.casefold() == agent_login.casefold() + @staticmethod + def _check_run_name(name: str) -> str: + return f"PR Agent - {name.capitalize()}" + def _publish_check_run(self, text: str, name: str) -> bool: - if not getattr(self, 'last_commit_id', None): - get_logger().error("Cannot publish check run without a commit SHA") - return False - conclusion = "neutral" - check_run_name = f"PR Agent - {name.capitalize()}" + check_run_name = self._check_run_name(name) summary = text.split("\n\n")[0] if "\n\n" in text else text[:200] summary = summary.strip(" #") # GitHub Checks API limits: text 65535 chars, summary 65535 chars max_text = 65535 if len(text) > max_text: text = text[:max_text] - create_body = { - "name": check_run_name, - "head_sha": self.last_commit_id.sha, + body = { "status": "completed", - "conclusion": conclusion, + "conclusion": "neutral", "output": { "title": check_run_name, "summary": summary[:300], "text": text, }, } - update_body = { + if self._upsert_check_run(name, body): + self._check_runs_in_progress.discard(name) + return True + return False + + def start_check_run(self, name: str, summary: str) -> bool: + """Open the tool's check run as in_progress before it has any output to publish. + + Automatic commands publish no progress comment, so this is the first sign that the + pull request was picked up. `_publish_check_run` completes the same run in place. + """ + body = { + "status": "in_progress", + "output": {"title": self._check_run_name(name), "summary": summary[:300]}, + } + if self._upsert_check_run(name, body): + self._check_runs_in_progress.add(name) + return True + return False + + def finish_check_run(self, name: str, conclusion: str, summary: str) -> bool: + """Complete a check run opened by `start_check_run` that no tool completed. + + A no-op when the tool already published its output to the run, so its conclusion + and text are kept. Otherwise the run would stay in_progress on the commit forever. + """ + if name not in self._check_runs_in_progress: + return False + body = { "status": "completed", "conclusion": conclusion, - "output": { - "title": check_run_name, - "summary": summary[:300], - "text": text, - }, + "output": {"title": self._check_run_name(name), "summary": summary[:300]}, } + if self._upsert_check_run(name, body): + self._check_runs_in_progress.discard(name) + return True + return False + + def _upsert_check_run(self, name: str, body: dict) -> bool: + """Update the `PR Agent - {Name}` check run on the head commit, creating it if absent.""" + if not getattr(self, 'last_commit_id', None): + get_logger().error("Cannot publish check run without a commit SHA") + return False + check_run_name = self._check_run_name(name) + create_body = {"name": check_run_name, "head_sha": self.last_commit_id.sha, **body} + update_body = body existing_id = self._check_run_ids.get(name) if not existing_id: existing_id = self._find_existing_check_run(check_run_name, self.last_commit_id.sha) @@ -676,7 +712,7 @@ def _publish_check_run(self, text: str, name: str) -> bool: self._check_run_ids[name] = data["id"] return True except Exception: - get_logger().warning("Failed to create check run, falling back to comment") + get_logger().warning("Failed to create check run") return False def _find_existing_check_run(self, check_run_name: str, head_sha: str) -> Optional[int]: diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index 2e2e496db2..ad94cdea3c 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -13,6 +13,7 @@ from starlette_context.middleware import RawContextMiddleware from pr_agent.agent.pr_agent import PRAgent, prepare_command +from pr_agent.algo.run_details import command_failed, init_run_details from pr_agent.config_loader import get_settings, global_settings from pr_agent.git_providers import get_git_provider, get_git_provider_with_context from pr_agent.git_providers.utils import apply_repo_settings @@ -178,6 +179,54 @@ async def handle_new_pr_opened(body: Dict[str, Any], get_logger().info(f"User {sender=} is not eligible to process PR {api_url=}") +# The check run each automatic command publishes its output to when `github.publish_as_check_run` +# is on, keyed by command and valued by the `name` the tool passes to `_publish_check_run`. +_AUTO_COMMAND_CHECK_RUNS = {"describe": "describe", "review": "review", "improve": "suggestions"} + + +def _check_run_provider(api_url: str): + """The provider whose check runs the automatic commands complete, or None when they publish none.""" + if not get_settings().get("github.publish_as_check_run", False): + return None + try: + provider = get_git_provider_with_context(pr_url=api_url) + except Exception as e: + get_logger().warning(f"Cannot open check runs for {api_url=}: {e}") + return None + return provider if callable(getattr(provider, "start_check_run", None)) else None + + +def _auto_command_check_run(command) -> tuple[str, str | None]: + """The command's leading token and the check run its tool publishes to, if any.""" + tokens = command if isinstance(command, list) else str(command).split() + action = tokens[0] if tokens else "" + return action, _AUTO_COMMAND_CHECK_RUNS.get(action.lstrip("/").lower()) + + +def _start_auto_command_check_run(provider, command) -> str | None: + """Open the command's check run as in_progress: the first sign the pull request was picked up.""" + action, name = _auto_command_check_run(command) + if provider is None or name is None: + return None + try: + provider.start_check_run(name, f"PR-Agent is running {action}") + except Exception as e: + get_logger().warning(f"Failed to open the {name} check run: {e}") + return name + + +def _finish_auto_command_check_run(provider, name: str | None, command, succeeded: bool) -> None: + """Complete the command's check run when its tool did not, so it cannot stay in_progress.""" + if provider is None or name is None: + return + action, _ = _auto_command_check_run(command) + summary = f"PR-Agent ran {action}" if succeeded else f"PR-Agent could not finish {action}" + try: + provider.finish_check_run(name, "success" if succeeded else "failure", summary) + except Exception as e: + get_logger().warning(f"Failed to complete the {name} check run: {e}") + + def _normalise_setting_list(value): if value is None: return [] @@ -543,16 +592,31 @@ async def _perform_auto_commands_github(commands_conf: str, agent: PRAgent, body get_logger().info(f"No {commands_conf} configured, skipping auto commands") return get_settings().set("config.is_auto_command", True) + provider = _check_run_provider(api_url) succeeded = True for command in commands: + check_run = None + command_succeeded = True try: new_command = prepare_command(command) get_logger().info(f"{commands_conf}. Performing auto command '{new_command}', for {api_url=}") + check_run = _start_auto_command_check_run(provider, new_command) + # Install a fresh collector so `command_failed()` below cannot read a verdict left + # behind by the previous command; the tool replaces it with its own on entry. + init_run_details() if await agent.handle_request(api_url, new_command) is False: - succeeded = False + command_succeeded = False + elif command_failed(): + # `propagate_tool_errors` is false by default, so a tool that failed internally + # still returns normally. Reporting that as success would put a green tick on a + # pull request that never got its review. + get_logger().warning(f"Command '{command}' reported success but recorded a failure") + command_succeeded = False except Exception as e: + command_succeeded = False get_logger().error(f"Failed to perform command {command}: {e}") - succeeded = False + _finish_auto_command_check_run(provider, check_run, command, command_succeeded) + succeeded = succeeded and command_succeeded return succeeded diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index a1fa4116b8..aee67f1d28 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -26,7 +26,7 @@ ) from pr_agent.algo.prompt_fragments import render_diff_hunk_format from pr_agent.algo.repo_context import build_repo_context -from pr_agent.algo.run_details import init_run_details, record_model_used +from pr_agent.algo.run_details import init_run_details, record_command_failure, record_model_used from pr_agent.algo.skills_loader import get_skills_context from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler @@ -461,6 +461,8 @@ async def run(self): self.git_provider.publish_comment("Failed to generate code suggestions for PR") except Exception as e: get_logger().exception(f"Failed to update persistent review, error: {e}") + # The status of the whole run must not read as success just because the error stopped here. + record_command_failure() if get_settings().config.get("propagate_tool_errors", False): raise diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 20f72c9887..5a4d595cc5 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -17,7 +17,7 @@ retry_with_fallback_models, ) from pr_agent.algo.repo_context import build_repo_context -from pr_agent.algo.run_details import init_run_details +from pr_agent.algo.run_details import init_run_details, record_command_failure from pr_agent.algo.skills_loader import get_skills_context from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler @@ -239,6 +239,8 @@ async def run(self): except Exception as e: get_logger().error(f"Error generating PR description {self.pr_id}: {e}", artifact={"traceback": traceback.format_exc()}) + # The status of the whole run must not read as success just because the error stopped here. + record_command_failure() if get_settings().config.get("propagate_tool_errors", False): raise finally: diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index c921debbf0..8d532b44fb 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -35,7 +35,7 @@ reconcile_review_findings, ) from pr_agent.algo.review_merge import merge_review_chunks -from pr_agent.algo.run_details import get_run_details, init_run_details, record_model_used +from pr_agent.algo.run_details import get_run_details, init_run_details, record_command_failure, record_model_used from pr_agent.algo.skills_loader import get_skills_context from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler @@ -450,6 +450,8 @@ async def run(self) -> None: review_error = e review_failed = True get_logger().error(f"Failed to review PR: {e}") + # The status of the whole run must not read as success just because the error stopped here. + record_command_failure() if get_settings().config.get("propagate_tool_errors", False): raise finally: diff --git a/tests/unittest/test_github_provider_check_runs.py b/tests/unittest/test_github_provider_check_runs.py index 0c140472eb..124d9957a7 100644 --- a/tests/unittest/test_github_provider_check_runs.py +++ b/tests/unittest/test_github_provider_check_runs.py @@ -32,6 +32,7 @@ def _make_provider(last_commit_sha="deadbeef", requester=None): p.pr = SimpleNamespace(_requester=requester or _FakeRequester()) p.last_commit_id = SimpleNamespace(sha=last_commit_sha) if last_commit_sha else None p._check_run_ids = {} + p._check_runs_in_progress = set() return p diff --git a/tests/unittest/test_run_status_acknowledgment.py b/tests/unittest/test_run_status_acknowledgment.py new file mode 100644 index 0000000000..1d35fa72e6 --- /dev/null +++ b/tests/unittest/test_run_status_acknowledgment.py @@ -0,0 +1,390 @@ +"""An automatic command should say it started before it has anything to publish. + +Automatic commands suppress the "Preparing review..." progress comment, so between opening a +pull request and the model answering there is no sign PR-Agent picked it up. When +`github.publish_as_check_run` is on, the tool's check run is opened as in_progress before the +command runs and completed in place by the tool, so there is one signal on the commit. +""" +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import pr_agent.servers.github_app as github_app +from pr_agent.algo.run_details import command_failed, init_run_details, record_command_failure +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.tools.pr_reviewer import PRReviewer +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +API_URL = "https://api.github.com/repos/org/repo/pulls/1" +CHECK_RUNS_URL = "https://api.github.com/repos/org/repo/check-runs" + + +@pytest.fixture +def check_runs_enabled(): + snapshot = snapshot_settings(["github.publish_as_check_run"]) + get_settings().set("github.publish_as_check_run", True) + yield + restore_settings(snapshot) + + +def _github(sha="abc123", existing=None): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "org/repo" + provider.base_url = "https://api.github.com" + provider.last_commit_id = SimpleNamespace(sha=sha) if sha else None + provider._check_run_ids = {} + provider._check_runs_in_progress = set() + requester = MagicMock() + + def request(method, url, **kwargs): + if method == "GET": + return {}, {"check_runs": existing or []} + if method == "POST": + return {}, {"id": 101} + return {}, {} + + requester.requestJsonAndCheck.side_effect = request + provider.pr = SimpleNamespace(_requester=requester) + return provider + + +def _requests(provider): + return [(c.args[0], c.args[1], c.kwargs["input"]) for c in provider.pr._requester.requestJsonAndCheck.call_args_list + if c.args[0] != "GET"] + + +# -------------------------------------------------------------------------------------- +# The provider: open in_progress, complete in place +# -------------------------------------------------------------------------------------- +def test_start_check_run_creates_it_in_progress(): + provider = _github() + + assert provider.start_check_run("review", "PR-Agent is running /review") is True + + [(method, url, body)] = _requests(provider) + assert (method, url) == ("POST", CHECK_RUNS_URL) + assert body["name"] == "PR Agent - Review" + assert body["head_sha"] == "abc123" + assert body["status"] == "in_progress" + assert "conclusion" not in body + assert body["output"] == {"title": "PR Agent - Review", "summary": "PR-Agent is running /review"} + assert provider._check_runs_in_progress == {"review"} + + +def test_start_check_run_reopens_the_run_already_on_the_commit(): + """A re-run on the same head updates the existing run rather than adding a second one.""" + provider = _github(existing=[{"name": "PR Agent - Review", "id": 55}]) + + provider.start_check_run("review", "working") + + [(method, url, body)] = _requests(provider) + assert (method, url) == ("PATCH", f"{CHECK_RUNS_URL}/55") + assert body["status"] == "in_progress" + + +def test_the_tool_completes_the_run_the_runner_opened(): + provider = _github() + provider.start_check_run("review", "working") + + assert provider._publish_check_run("## Review\n\nlooks fine", "review") is True + + assert [(m, u) for m, u, _ in _requests(provider)] == [("POST", CHECK_RUNS_URL), ("PATCH", f"{CHECK_RUNS_URL}/101")] + completed = _requests(provider)[-1][2] + assert completed["status"] == "completed" + assert completed["conclusion"] == "neutral" + assert completed["output"]["text"] == "## Review\n\nlooks fine" + # The tool owns the run now: the runner's completion must not overwrite its output. + assert provider.finish_check_run("review", "failure", "could not finish") is False + assert len(_requests(provider)) == 2 + + +def test_finish_check_run_completes_a_run_the_tool_left_open(): + provider = _github() + provider.start_check_run("review", "working") + + assert provider.finish_check_run("review", "failure", "PR-Agent could not finish /review") is True + + completed = _requests(provider)[-1] + assert completed[:2] == ("PATCH", f"{CHECK_RUNS_URL}/101") + assert completed[2] == { + "status": "completed", + "conclusion": "failure", + "output": {"title": "PR Agent - Review", "summary": "PR-Agent could not finish /review"}, + } + assert provider._check_runs_in_progress == set() + + +def test_finish_check_run_ignores_a_run_it_did_not_open(): + provider = _github() + + assert provider.finish_check_run("review", "success", "done") is False + assert _requests(provider) == [] + + +def test_start_check_run_without_a_commit_sha_reports_failure(): + provider = _github(sha=None) + + assert provider.start_check_run("review", "working") is False + assert provider._check_runs_in_progress == set() + + +def test_start_check_run_survives_an_api_failure(): + provider = _github() + provider.pr._requester.requestJsonAndCheck.side_effect = RuntimeError("boom") + + assert provider.start_check_run("review", "working") is False + assert provider._check_runs_in_progress == set() + + +# -------------------------------------------------------------------------------------- +# Wiring: the automatic-command runner +# -------------------------------------------------------------------------------------- +@pytest.fixture +def auto_commands(monkeypatch): + settings = get_settings() + snapshot = snapshot_settings(["github_app.feedback_on_draft_pr", "config.disable_auto_feedback"]) + provider = MagicMock() + monkeypatch.setattr(github_app, "get_git_provider_with_context", lambda pr_url: provider) + monkeypatch.setattr(github_app, "get_pr_commands", lambda name: ["/review"]) + monkeypatch.setattr(github_app, "should_process_pr_logic", lambda body: True) + monkeypatch.setattr(github_app, "prepare_command", lambda command: command) + settings.set("github_app.feedback_on_draft_pr", True) + settings.set("config.disable_auto_feedback", False) + yield provider + restore_settings(snapshot) + + +@pytest.fixture +def restored_config(): + """Override a `config` key and put the original back through the same API.""" + settings = get_settings() + original = {} + + def _set(key, value): + original.setdefault(key, settings.get(f"config.{key}", None)) + settings.set(f"config.{key}", value) + + yield _set + for key, value in original.items(): + settings.set(f"config.{key}", value) + + +def _agent(outcome): + agent = MagicMock() + events = [] + + async def handle_request(api_url, command, notify=None): + events.append(("run", command)) + if isinstance(outcome, Exception): + raise outcome + if callable(outcome): + return outcome(command) + return outcome + + agent.handle_request = handle_request + return agent, events + + +def _record(provider, events): + provider.start_check_run.side_effect = lambda name, summary: events.append(("start", name, summary)) + provider.finish_check_run.side_effect = ( + lambda name, conclusion, summary: events.append(("finish", name, conclusion, summary))) + + +async def test_a_command_opens_its_check_run_before_running_and_completes_it_after(check_runs_enabled, auto_commands): + agent, events = _agent(True) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [ + ("start", "review", "PR-Agent is running /review"), + ("run", "/review"), + ("finish", "review", "success", "PR-Agent ran /review"), + ] + assert result is True + + +async def test_a_failed_command_completes_its_check_run_as_failure(check_runs_enabled, auto_commands): + agent, events = _agent(False) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "failure", "PR-Agent could not finish /review") + assert result is False + + +async def test_a_raising_command_completes_its_check_run_as_failure(check_runs_enabled, auto_commands): + agent, events = _agent(RuntimeError("boom")) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "failure", "PR-Agent could not finish /review") + assert result is False + + +async def test_each_command_gets_its_own_check_run(check_runs_enabled, auto_commands, monkeypatch): + monkeypatch.setattr(github_app, "get_pr_commands", + lambda name: ["/describe --pr_description.final_update_message=false", "/review", "/improve"]) + agent, events = _agent(True) + _record(auto_commands, events) + + await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert [e[:2] for e in events if e[0] != "run"] == [ + ("start", "describe"), ("finish", "describe"), + ("start", "review"), ("finish", "review"), + ("start", "suggestions"), ("finish", "suggestions"), + ] + + +async def test_a_command_without_a_check_run_opens_none(check_runs_enabled, auto_commands, monkeypatch): + monkeypatch.setattr(github_app, "get_pr_commands", lambda name: ["/ask something"]) + agent, events = _agent(True) + _record(auto_commands, events) + + await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/ask something")] + + +async def test_nothing_is_opened_when_the_setting_is_off(auto_commands): + """Control: the shipped default changes nothing.""" + snapshot = snapshot_settings(["github.publish_as_check_run"]) + get_settings().set("github.publish_as_check_run", False) + try: + agent, events = _agent(True) + + await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + finally: + restore_settings(snapshot) + + auto_commands.start_check_run.assert_not_called() + auto_commands.finish_check_run.assert_not_called() + assert events == [("run", "/review")] + + +async def test_a_failing_acknowledgement_does_not_stop_the_command(check_runs_enabled, auto_commands): + """The check run is a courtesy: a provider that cannot write it must not cost the review.""" + auto_commands.start_check_run.side_effect = RuntimeError("no checks: write") + auto_commands.finish_check_run.side_effect = RuntimeError("no checks: write") + agent, events = _agent(True) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/review")] + assert result is True + + +async def test_a_provider_that_cannot_be_built_does_not_stop_the_command( + check_runs_enabled, auto_commands, monkeypatch): + def explode(pr_url): + raise ValueError("Failed to get git provider") + + monkeypatch.setattr(github_app, "get_git_provider_with_context", explode) + agent, events = _agent(True) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/review")] + assert result is True + + +async def test_a_provider_without_check_runs_is_left_alone(check_runs_enabled, auto_commands, monkeypatch): + """Selected by capability, not by provider type: anything without `start_check_run` is skipped.""" + monkeypatch.setattr(github_app, "get_git_provider_with_context", lambda pr_url: MagicMock(spec=[])) + agent, events = _agent(True) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/review")] + assert result is True + + +# -------------------------------------------------------------------------------------- +# A tool that swallows its own error must not be reported as a success. +# +# `propagate_tool_errors` is false by default, so `PRReviewer.run()` logs the failure and +# returns normally. `handle_request` therefore answers True, and without the run-details +# verdict the check run would complete as a success on a pull request that got no review. +# -------------------------------------------------------------------------------------- +async def test_a_swallowed_tool_error_completes_the_check_run_as_failure(check_runs_enabled, auto_commands): + def swallow(command): + init_run_details() + record_command_failure() + return True + + agent, events = _agent(swallow) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "failure", "PR-Agent could not finish /review") + assert result is False + + +async def test_a_verdict_does_not_leak_into_the_next_command(check_runs_enabled, auto_commands, monkeypatch): + """The collector is a ContextVar, so a stale failure must not condemn the command after it.""" + monkeypatch.setattr(github_app, "get_pr_commands", lambda name: ["/describe", "/review"]) + + def outcome(command): + if command == "/describe": + init_run_details() + record_command_failure() + # A tool that never installs a collector of its own must read no verdict. + return True + + agent, events = _agent(outcome) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert [e for e in events if e[0] == "run"] == [("run", "/describe"), ("run", "/review")] + assert [e for e in events if e[0] == "finish"] == [ + ("finish", "describe", "failure", "PR-Agent could not finish /describe"), + ("finish", "review", "success", "PR-Agent ran /review"), + ] + assert result is False + assert command_failed() is False + + +async def test_a_clean_run_still_reports_success(check_runs_enabled, auto_commands): + """Control: a tool that installs a collector and records nothing is a success.""" + def clean(command): + init_run_details() + return True + + agent, events = _agent(clean) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "success", "PR-Agent ran /review") + assert result is True + + +async def test_the_reviewer_records_a_swallowed_failure(monkeypatch, restored_config): + """End to end through the real `PRReviewer.run()`, with the shipped default settings.""" + restored_config("propagate_tool_errors", False) + restored_config("publish_output", False) + tool = PRReviewer.__new__(PRReviewer) + tool.git_provider = MagicMock() + tool.incremental = SimpleNamespace(is_incremental=False) + tool.pr_url = API_URL + monkeypatch.setattr(PRReviewer, "_prepare_prediction", MagicMock(side_effect=RuntimeError("boom"))) + + init_run_details() + await tool.run() + + assert command_failed() is True + + +async def test_a_reviewer_run_that_works_records_nothing(monkeypatch): + """Control: the flag is only set by the swallow path.""" + init_run_details() + + assert command_failed() is False From bafc25c1435e494e703a945147ae431374bbd152 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Sat, 19 Sep 2026 10:18:15 -0500 Subject: [PATCH 175/238] fix(bitbucket): surface source write failures (#3500) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/git_providers/bitbucket_provider.py | 6 +- tests/unittest/test_bitbucket_provider.py | 80 +++++++++++++++++++- tests/unittest/test_pr_update_changelog.py | 56 ++++++++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index 01d4082d12..de67d04c82 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -672,10 +672,8 @@ def create_or_update_pr_file(self, file_path: str, branch: str, contents="", mes "branch": branch } headers = {'Authorization': self.headers['Authorization']} if 'Authorization' in self.headers else {} - try: - requests.request("POST", url, headers=headers, data=data, files=files) - except Exception: - get_logger().exception(f"Failed to create empty file {file_path} in branch {branch}") + response = requests.request("POST", url, headers=headers, data=data, files=files) + response.raise_for_status() def _get_pr_file_content(self, remote_link: str): try: diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index 41e2d493eb..eea6b74769 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -3,7 +3,9 @@ import pytest from atlassian.bitbucket import Bitbucket -from requests.exceptions import HTTPError +from requests import Request, Response +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import HTTPError, Timeout from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.algo.utils import ( @@ -24,6 +26,24 @@ def _added_file(filename="src/example.py", lines=("a = 1", "b = 2", "c = 3")): class TestBitbucketProvider: + @staticmethod + def _source_write_provider(): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.workspace_slug = "workspace" + provider.repo_slug = "repository" + provider.headers = {"Authorization": "Bearer test-value"} + return provider + + @staticmethod + def _source_write_response(status_code: int): + url = "https://api.bitbucket.org/2.0/repositories/workspace/repository/src/" + response = Response() + response.status_code = status_code + response.url = url + response.request = Request("POST", url).prepare() + response._content = b"response-body-sentinel" + return response + @staticmethod def _code_suggestion(line: int, end_line: int = None): return { @@ -103,6 +123,64 @@ def test_publish_description_accepts_success_response(self, status_code): with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response): provider.publish_description("AI title", "Updated description") + @pytest.mark.parametrize("status_code", [200, 201, 204]) + def test_create_or_update_pr_file_accepts_success_response(self, status_code): + provider = self._source_write_provider() + response = self._source_write_response(status_code) + + with patch( + "pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response + ) as request: + result = provider.create_or_update_pr_file( + "CHANGELOG.md", "feature", "new content", "Update changelog" + ) + + assert result is None + request.assert_called_once() + assert request.call_args.args == ( + "POST", + "https://api.bitbucket.org/2.0/repositories/workspace/repository/src/", + ) + assert request.call_args.kwargs["data"] == {"message": "Update changelog", "branch": "feature"} + assert request.call_args.kwargs["files"] == {"CHANGELOG.md": "new content"} + assert set(request.call_args.kwargs["headers"]) == {"Authorization"} + + @pytest.mark.parametrize("status_code", [401, 403, 409, 500, 503]) + def test_create_or_update_pr_file_propagates_http_errors(self, status_code): + provider = self._source_write_provider() + response = self._source_write_response(status_code) + + with ( + patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request, + patch("pr_agent.git_providers.bitbucket_provider.get_logger") as get_logger, + pytest.raises(HTTPError) as raised, + ): + provider.create_or_update_pr_file( + "CHANGELOG.md", "feature", "new content", "Update changelog" + ) + + assert raised.value.response is response + request.assert_called_once() + get_logger.assert_not_called() + + @pytest.mark.parametrize("error_type", [RequestsConnectionError, Timeout]) + def test_create_or_update_pr_file_propagates_transport_errors(self, error_type): + provider = self._source_write_provider() + error = error_type("transport failed") + + with ( + patch("pr_agent.git_providers.bitbucket_provider.requests.request", side_effect=error) as request, + patch("pr_agent.git_providers.bitbucket_provider.get_logger") as get_logger, + pytest.raises(error_type) as raised, + ): + provider.create_or_update_pr_file( + "CHANGELOG.md", "feature", "new content", "Update changelog" + ) + + assert raised.value is error + request.assert_called_once() + get_logger.assert_not_called() + def test_parse_pr_url(self): url = "https://bitbucket.org/WORKSPACE_XYZ/MY_TEST_REPO/pull-requests/321" workspace_slug, repo_slug, pr_number = BitbucketProvider._parse_pr_url(url) diff --git a/tests/unittest/test_pr_update_changelog.py b/tests/unittest/test_pr_update_changelog.py index 47bc92a7e2..90cee98091 100644 --- a/tests/unittest/test_pr_update_changelog.py +++ b/tests/unittest/test_pr_update_changelog.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from requests.exceptions import HTTPError from pr_agent.git_providers.github_provider import GithubProvider from pr_agent.tools.pr_update_changelog import PRUpdateChangelog @@ -561,6 +562,61 @@ def test_push_changelog_update_creates_review_when_supported(self, changelog_too ) mock_git_provider.publish_comment.assert_not_called() + def test_push_changelog_update_stops_success_follow_up_after_write_failure( + self, changelog_tool, mock_git_provider + ): + write_error = HTTPError("403 Client Error") + mock_git_provider.create_or_update_pr_file.side_effect = write_error + mock_git_provider.get_pr_branch.return_value = "feature-branch" + mock_git_provider.supports_changelog_update_review.return_value = True + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.sleep" + ) as sleep: + mock_settings.return_value.pr_update_changelog.get.return_value = True + + with pytest.raises(HTTPError) as raised: + changelog_tool._push_changelog_update("new content", "answer") + + assert raised.value is write_error + mock_git_provider.create_or_update_pr_file.assert_called_once() + sleep.assert_not_called() + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.pr.create_review.assert_not_called() + mock_git_provider.publish_comment.assert_not_called() + + @pytest.mark.asyncio + async def test_run_preserves_write_failure_after_temporary_comment_cleanup( + self, changelog_tool, mock_git_provider + ): + write_error = HTTPError("403 Client Error") + mock_git_provider.create_or_update_pr_file.side_effect = write_error + mock_git_provider.remove_initial_comment.side_effect = RuntimeError("cleanup failed") + mock_git_provider.supports_changelog_update_review.return_value = True + changelog_tool.commit_changelog = True + changelog_tool.prediction = "## v1.1.0\n- New feature" + + with ( + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), + patch("pr_agent.tools.pr_update_changelog.sleep") as sleep, + ): + mock_settings.return_value.config.publish_output = True + mock_settings.return_value.pr_update_changelog.get.return_value = True + mock_settings.return_value.get.return_value = {} + + with pytest.raises(HTTPError) as raised: + await changelog_tool.run() + + assert raised.value is write_error + mock_git_provider.publish_comment.assert_called_once_with( + "Preparing changelog updates...", is_temporary=True + ) + mock_git_provider.remove_initial_comment.assert_called_once_with() + sleep.assert_not_called() + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.pr.create_review.assert_not_called() + def test_push_changelog_update_skips_review_when_not_supported(self, changelog_tool, mock_git_provider): """A provider without the capability is never asked for a commit-scoped review.""" mock_git_provider.create_or_update_pr_file = MagicMock() From b09e90dfda9fb3f23d93222655b0942fde2fda0e Mon Sep 17 00:00:00 2001 From: Rajin Khan Date: Sat, 19 Sep 2026 22:11:33 +0600 Subject: [PATCH 176/238] refactor: remove 29 unreachable definitions (#3499) Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- .../git_providers/azuredevops_provider.py | 6 - pr_agent/git_providers/bitbucket_provider.py | 23 --- .../bitbucket_server_provider.py | 34 ----- pr_agent/git_providers/git_provider.py | 12 -- pr_agent/git_providers/gitea_provider.py | 16 --- pr_agent/git_providers/github_provider.py | 59 -------- pr_agent/git_providers/gitlab_provider.py | 45 +----- pr_agent/git_providers/local_git_provider.py | 4 - pr_agent/git_providers/plain_diff_provider.py | 5 - .../default_identity_provider.py | 3 - .../identity_providers/identity_provider.py | 4 - pr_agent/telemetry/prometheus.py | 5 - pr_agent/tools/pr_code_suggestions.py | 11 -- tests/unittest/test_unreachable_code.py | 132 ++++++++++++++++++ 14 files changed, 133 insertions(+), 226 deletions(-) create mode 100644 tests/unittest/test_unreachable_code.py diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index ca14e89b2c..f0641a38d7 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -1663,12 +1663,6 @@ def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) - def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: return True - def set_like(self, thread_id: int, comment_id: int, create: bool = True): - if create: - self.azure_devops_client.create_like(self.repo_slug, self.pr_num, thread_id, comment_id, project=self.workspace_slug) - else: - self.azure_devops_client.delete_like(self.repo_slug, self.pr_num, thread_id, comment_id, project=self.workspace_slug) - def set_thread_status(self, thread_id: int, status: str) -> bool: try: self.azure_devops_client.update_thread( diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index de67d04c82..cbb1c3fad0 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -493,26 +493,6 @@ def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_l link = f"{self.pr_url}/#L{relevant_file}T{relevant_line_start}" return link - def generate_link_to_relevant_line_number(self, suggestion) -> str: - try: - relevant_file = suggestion['relevant_file'].strip('`').strip("'").rstrip() - relevant_line_str = suggestion['relevant_line'].rstrip() - if not relevant_line_str: - return "" - - diff_files = self.get_diff_files() - position, absolute_position = find_line_number_of_relevant_line_in_file \ - (diff_files, relevant_file, relevant_line_str) - - if absolute_position != -1 and self.pr_url: - link = f"{self.pr_url}/#L{relevant_file}T{absolute_position}" - return link - except Exception as e: - if get_verbosity_level() >= 2: - get_logger().info(f"Failed adding line link, error: {e}") - - return "" - def publish_inline_comments(self, comments: list[dict]) -> bool: publishable_count = 0 published_count = 0 @@ -557,9 +537,6 @@ def get_repo_default_branch(self): except: return self.pr.destination_branch - def get_pr_owner_id(self) -> str | None: - return self.workspace_slug - def get_owning_namespace(self) -> str | None: if not getattr(self, "headers", None): return None diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index a85537232e..0adc500f37 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -486,37 +486,6 @@ def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_l link = f"{self.pr_url}/diff#{quote_plus(relevant_file)}?t={relevant_line_start}" return link - def generate_link_to_relevant_line_number(self, suggestion) -> str: - try: - relevant_file = suggestion['relevant_file'].strip('`').strip("'").rstrip() - relevant_line_str = suggestion['relevant_line'].rstrip() - if not relevant_line_str: - return "" - - diff_files = self.get_diff_files() - position, absolute_position = find_line_number_of_relevant_line_in_file \ - (diff_files, relevant_file, relevant_line_str) - - if absolute_position != -1: - if self.pr: - link = f"{self.pr_url}/diff#{quote_plus(relevant_file)}?t={absolute_position}" - return link - else: - if get_verbosity_level() >= 2: - get_logger().info(f"Failed adding line link to '{relevant_file}' since PR not set") - else: - if get_verbosity_level() >= 2: - get_logger().info(f"Failed adding line link to '{relevant_file}' since position not found") - - if absolute_position != -1 and self.pr_url: - link = f"{self.pr_url}/diff#{quote_plus(relevant_file)}?t={absolute_position}" - return link - except Exception as e: - if get_verbosity_level() >= 2: - get_logger().info(f"Failed adding line link to '{relevant_file}', error: {e}") - - return "" - def publish_inline_comments(self, comments: list[dict]) -> bool: publishable_count = 0 published_count = 0 @@ -562,9 +531,6 @@ def get_languages(self): def get_pr_branch(self): return self.pr.fromRef['displayId'] - def get_pr_owner_id(self) -> str | None: - return self.workspace_slug - def get_owning_namespace(self) -> str | None: return self.workspace_slug diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index 1a8aa76970..7f479d6521 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -432,12 +432,6 @@ def get_pr_description_full(self) -> str: def edit_comment(self, comment, body: str): pass - def edit_comment_from_comment_id(self, comment_id: int, body: str): - pass - - def get_comment_body_from_comment_id(self, comment_id: int) -> str: - pass - def reply_to_comment_from_comment_id(self, comment_id: int, body: str): pass @@ -647,9 +641,6 @@ def get_repo_context_ref(self, from_default_branch: bool = False) -> Optional[st """ return None - def get_workspace_name(self): - return "" - def get_pr_id(self): return "" @@ -880,9 +871,6 @@ def publish_labels(self, labels): def get_pr_labels(self, update=False): pass - def get_repo_labels(self): - pass - def add_reaction(self, issue_comment_id: int, reaction: str) -> Optional[int]: """Add a named reaction to a comment, returning its id. diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 29baef5024..058e9e44e2 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -311,9 +311,6 @@ def get_pr_url(self) -> str: return f"{self.base_url_html}/{self.owner}/{self.repo}/pulls/{self.pr_number}" return self.pr_url - def get_issue_url(self) -> str: - return self.issue_url - def get_latest_commit_url(self) -> str: return self.last_commit.html_url if self.last_commit else "" @@ -1191,19 +1188,6 @@ def list_all_commits(self, owner: str, repo: str): repo=repo ) - def add_reviewer(self, owner: str, repo: str, pr_number: int, reviewers: List[str]): - body = { - "reviewers": reviewers - } - return self.api_client.call_api( - '/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers', - 'POST', - path_params={'owner': owner, 'repo': repo, 'pr_number': pr_number}, - body=body, - response_type='Repository', - auth_settings=['AuthorizationHeaderToken'] - ) - def add_reaction_comment(self, owner: str, repo: str, comment_id: int, reaction: str): body = { "content": reaction diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index adb61a714d..10fb5d4b67 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1,7 +1,6 @@ import copy import difflib import hashlib -import itertools import json import os import re @@ -1183,17 +1182,6 @@ def edit_comment(self, comment, body: str): get_logger().exception("Failed to edit github comment", artifact={"error": e}) return False - def edit_comment_from_comment_id(self, comment_id: int, body: str): - try: - # self.pr.get_issue_comment(comment_id).edit(body) - body = self.limit_output_characters(body, self.max_comment_chars) - headers, data_patch = self.pr._requester.requestJsonAndCheck( - "PATCH", f"{self.base_url}/repos/{self.repo}/issues/comments/{comment_id}", - input={"body": body} - ) - except Exception as e: - get_logger().exception(f"Failed to edit comment, error: {e}") - def reply_to_comment_from_comment_id(self, comment_id: int, body: str): try: # self.pr.get_issue_comment(comment_id).edit(body) @@ -1205,17 +1193,6 @@ def reply_to_comment_from_comment_id(self, comment_id: int, body: str): except Exception as e: get_logger().exception(f"Failed to reply comment, error: {e}") - def get_comment_body_from_comment_id(self, comment_id: int): - try: - # self.pr.get_issue_comment(comment_id).edit(body) - headers, data_patch = self.pr._requester.requestJsonAndCheck( - "GET", f"{self.base_url}/repos/{self.repo}/issues/comments/{comment_id}" - ) - return data_patch.get("body","") - except Exception as e: - get_logger().exception(f"Failed to edit comment, error: {e}") - return None - def remove_initial_comment(self): try: for comment in getattr(self.pr, 'comments_list', []): @@ -1240,11 +1217,6 @@ def get_languages(self): def get_pr_branch(self): return self.pr.head.ref - def get_pr_owner_id(self) -> str | None: - if not self.repo: - return None - return self.repo.split('/')[0] - def get_owning_namespace(self, *, resolved: bool = False) -> Optional[str]: # Be robust to providers built without full __init__ (e.g. __new__ in tests/helpers): # without a repo there is no org to resolve, so skip global settings quietly. @@ -1548,9 +1520,6 @@ def get_repo_context_ref(self, from_default_branch: bool = False) -> Optional[st get_logger().debug(f"Could not resolve the default branch revision for repo context: {e}") return None - def get_workspace_name(self): - return self.repo.split('/')[0] - # The reaction API accepts only this closed set; anything else is rejected with 422. SUPPORTED_REACTIONS = ("+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes") @@ -1764,10 +1733,6 @@ def get_pr_labels(self, update=False): get_logger().exception(f"Failed to get labels, error: {e}") return [] - def get_repo_labels(self): - labels = self.repo_obj.get_labels() - return [label for label in itertools.islice(labels, 50)] - def get_commit_messages(self) -> str: """ Retrieves the commit messages of a pull request. @@ -1786,30 +1751,6 @@ def get_commit_messages(self) -> str: commit_messages_str = clip_tokens(commit_messages_str, max_tokens) return commit_messages_str - def generate_link_to_relevant_line_number(self, suggestion) -> str: - try: - relevant_file = suggestion['relevant_file'].strip('`').strip("'").strip('\n') - relevant_line_str = suggestion['relevant_line'].strip('\n') - if not relevant_line_str: - return "" - - position, absolute_position = find_line_number_of_relevant_line_in_file \ - (self.diff_files, relevant_file, relevant_line_str) - - if absolute_position != -1: - # # link to right file only - # link = f"https://github.com/{self.repo}/blob/{self.pr.head.sha}/{relevant_file}" \ - # + "#" + f"L{absolute_position}" - - # link to diff - sha_file = hashlib.sha256(relevant_file.encode('utf-8')).hexdigest() - link = f"{self.base_url_html}/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{absolute_position}" - return link - except Exception as e: - get_logger().info(f"Failed adding line link, error: {e}") - - return "" - def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str: sha_file = hashlib.sha256(relevant_file.encode('utf-8')).hexdigest() relevant_line_start, relevant_line_end = self._normalize_line_range( diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index ecea35b5a7..7991377c6a 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -28,11 +28,10 @@ PRCodeSuggestionsIdentity, clip_tokens, comment_matches_any_identity, - find_line_number_of_relevant_line_in_file, get_pr_review_comment_identifiers, load_large_diff, ) -from ..config_loader import get_settings, get_verbosity_level +from ..config_loader import get_settings from ..log import get_logger from .git_provider import ( MAX_FILES_ALLOWED_FULL, @@ -1315,12 +1314,6 @@ def removed_lines_for(position) -> dict: f"Resolved {resolved} fixed inline thread(s) on merge request {self.id_mr}") return resolved - def edit_comment_from_comment_id(self, comment_id: int, body: str): - body = self.limit_output_characters(body, self.max_comment_chars) - comment = self.mr.notes.get(comment_id) - comment.body = body - comment.save() - def reply_to_comment_from_comment_id(self, comment_id: int, body: str): body = self.limit_output_characters(body, self.max_comment_chars) discussion = self.mr.discussions.get(comment_id) @@ -1336,13 +1329,6 @@ def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, absolute_position: int = None): raise NotImplementedError("GitLab provider does not support creating inline comments yet") - def create_inline_comments(self, comments: list[dict]): - raise NotImplementedError("GitLab provider does not support publishing inline comments yet") - - def get_comment_body_from_comment_id(self, comment_id: int): - comment = self.mr.notes.get(comment_id).body - return comment - def send_inline_comment(self, body: str, edit_type: str, found: bool, relevant_file: str, relevant_line_in_file: str, source_line_no: int, target_file: str, target_line_no: int, @@ -1967,9 +1953,6 @@ def get_repo_context_ref(self, from_default_branch: bool = False) -> Optional[st self._repo_context_default_branch = self.gl.projects.get(self.id_project).default_branch return self._repo_context_default_branch - def get_workspace_name(self): - return self.id_project.split('/')[0] - def add_reaction(self, issue_comment_id: int, reaction: str) -> Optional[int]: try: if not self.id_mr: @@ -2141,9 +2124,6 @@ def get_pr_labels(self, update=False): get_logger().warning(f"Failed to refresh merge request {self.id_mr}, using cached labels, error: {e}") return self._read_mr_labels() - def get_repo_labels(self): - return self.gl.projects.get(self.id_project).labels.list() - def get_commit_messages(self) -> str: """ Retrieves the commit messages of a pull request. @@ -2197,29 +2177,6 @@ def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_l return link - def generate_link_to_relevant_line_number(self, suggestion) -> str: - try: - relevant_file = suggestion['relevant_file'].strip('`').strip("'").rstrip() - relevant_line_str = suggestion['relevant_line'].rstrip() - if not relevant_line_str: - return "" - - position, absolute_position = find_line_number_of_relevant_line_in_file \ - (self.diff_files, relevant_file, relevant_line_str) - - if absolute_position != -1: - # link to right file only - link = self.get_line_link(relevant_file, absolute_position) - - # # link to diff - # sha_file = hashlib.sha1(relevant_file.encode('utf-8')).hexdigest() - # link = f"{self.pr.web_url}/diffs#{sha_file}_{absolute_position}_{absolute_position}" - return link - except Exception as e: - if get_verbosity_level() >= 2: - get_logger().info(f"Failed adding line link, error: {e}") - - return "" #Clone related def _prepare_clone_url_with_token(self, repo_url_to_clone: str) -> str | None: if "gitlab." not in repo_url_to_clone: diff --git a/pr_agent/git_providers/local_git_provider.py b/pr_agent/git_providers/local_git_provider.py index d794cf1528..3c5df4428d 100644 --- a/pr_agent/git_providers/local_git_provider.py +++ b/pr_agent/git_providers/local_git_provider.py @@ -151,10 +151,6 @@ def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in def publish_inline_comments(self, comments: list[dict]): raise NotImplementedError('Publishing inline comments is not implemented for the local git provider') - def publish_code_suggestion(self, body: str, relevant_file: str, - relevant_lines_start: int, relevant_lines_end: int): - raise NotImplementedError('Publishing code suggestions is not implemented for the local git provider') - def publish_code_suggestions(self, code_suggestions: list) -> bool: return self.publish_code_suggestions_artifact(code_suggestions) diff --git a/pr_agent/git_providers/plain_diff_provider.py b/pr_agent/git_providers/plain_diff_provider.py index 2f55d5a57a..fb499c87f1 100644 --- a/pr_agent/git_providers/plain_diff_provider.py +++ b/pr_agent/git_providers/plain_diff_provider.py @@ -179,11 +179,6 @@ def get_pr_branch(self): return "" # ---- code suggestions: rendered to stdout/--output (no hosting platform) ---- - def publish_code_suggestion(self, body: str, relevant_file: str, - relevant_lines_start: int, relevant_lines_end: int): - location = f"{relevant_file}:{relevant_lines_start}-{relevant_lines_end}" - self._write_output(f"### {location}\n\n{body}") - def publish_code_suggestions(self, code_suggestions: list) -> bool: # The 'improve' tool calls this unconditionally; render the suggestions # as a single markdown document to stdout/--output instead of pushing diff --git a/pr_agent/identity_providers/default_identity_provider.py b/pr_agent/identity_providers/default_identity_provider.py index c542e1c28a..10701f8a32 100644 --- a/pr_agent/identity_providers/default_identity_provider.py +++ b/pr_agent/identity_providers/default_identity_provider.py @@ -4,6 +4,3 @@ class DefaultIdentityProvider(IdentityProvider): def verify_eligibility(self, git_provider, git_provider_id, pr_url): return Eligibility.ELIGIBLE - - def inc_invocation_count(self, git_provider, git_provider_id): - pass diff --git a/pr_agent/identity_providers/identity_provider.py b/pr_agent/identity_providers/identity_provider.py index 0fdb9a3767..1c6666b355 100644 --- a/pr_agent/identity_providers/identity_provider.py +++ b/pr_agent/identity_providers/identity_provider.py @@ -12,7 +12,3 @@ class IdentityProvider(ABC): @abstractmethod def verify_eligibility(self, git_provider, git_provider_id, pr_url): pass - - @abstractmethod - def inc_invocation_count(self, git_provider, git_provider_id): - pass diff --git a/pr_agent/telemetry/prometheus.py b/pr_agent/telemetry/prometheus.py index 8d62454897..8c4a3ecb18 100644 --- a/pr_agent/telemetry/prometheus.py +++ b/pr_agent/telemetry/prometheus.py @@ -68,11 +68,6 @@ def prometheus_metrics_enabled() -> bool: ) -def prometheus_registry(): - """The registry metric objects are created on (single-process view).""" - return _registry() - - def _registry(): global _prometheus_registry if _prometheus_registry is None: diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index aee67f1d28..9b9ac048c1 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -9,8 +9,6 @@ from functools import partial from typing import Dict, List, Optional -from jinja2 import Environment, StrictUndefined - from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.git_patch_processing import decouple_and_convert_to_hunks_with_lines_numbers @@ -863,15 +861,6 @@ async def _prepare_prediction(self, model: str) -> dict: data = self.prediction return data - def _render_prediction_prompts(self, patches_diff: str, patches_diff_no_line_number: str) -> tuple[str, str]: - variables = copy.deepcopy(self.vars) - variables["diff"] = patches_diff # update diff - variables["diff_no_line_numbers"] = patches_diff_no_line_number # update diff - environment = Environment(undefined=StrictUndefined) - system_prompt = environment.from_string(self.pr_code_suggestions_prompt_system).render(variables) - user_prompt = environment.from_string(self.pr_code_suggestions_prompt_user).render(variables) - return system_prompt, user_prompt - async def _get_prediction(self, model: str, patches_diff: str, patches_diff_no_line_number: str) -> dict: budget = getattr(self, "_suggestion_attempt_budget", None) if budget is None or budget.model != model: diff --git a/tests/unittest/test_unreachable_code.py b/tests/unittest/test_unreachable_code.py new file mode 100644 index 0000000000..0811466c0b --- /dev/null +++ b/tests/unittest/test_unreachable_code.py @@ -0,0 +1,132 @@ +"""Regression guard for unreachable code. + +Every function and method defined in pr_agent/ must be referenced somewhere in +pr_agent/, tests/ or docs/. Detection is deliberately conservative: entry points +that are named from outside Python (route handlers, Lambda handlers) and members +of third-party interfaces called by the library that owns them are recorded in +the allowlist with a note pointing at the caller. + +Abstract methods, properties, validators and dunders are skipped: they are +reached through the interface or the descriptor protocol rather than by name. + +Adding a definition nothing calls fails this test, so the dead-code ledger +cleared in #3182 cannot silently regrow. +""" + +import ast +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PR_AGENT_SOURCE = ROOT / "pr_agent" +SEARCH_ROOTS = (ROOT / "pr_agent", ROOT / "tests", ROOT / "docs") +SEARCH_SUFFIXES = (".py", ".md", ".toml", ".yaml", ".yml") + +# Decorators that mark a definition as reachable from outside Python. +_ENTRY_POINT_DECORATORS = ( + "get", "post", "put", "delete", "patch", "route", "head", "options", + "on_event", "exception_handler", "middleware", "websocket", +) +# Decorators after which the name is reached through the descriptor protocol. +_INDIRECT_DECORATORS = ("property", "setter", "deleter", "validator", "abstractmethod") + +# Definitions with no in-repo reference that are nonetheless live. Each entry +# documents what calls it. +_ALLOWLIST = { + ( + "pr_agent/servers/github_lambda_webhook.py", + "lambda_handler", + ): "AWS Lambda entry point, named in docker/Dockerfile.lambda", + ( + "pr_agent/servers/gitlab_lambda_webhook.py", + "lambda_handler", + ): "AWS Lambda entry point, named in docker/Dockerfile.lambda", + ( + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + "get_aws_security_credentials", + ): "google.auth.aws.AwsSecurityCredentialsSupplier interface method, called by google-auth", + ( + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + "get_aws_region", + ): "google.auth.aws.AwsSecurityCredentialsSupplier interface method, called by google-auth", +} + + +def _is_skipped(node) -> bool: + name = node.name + if name.startswith("__") and name.endswith("__"): + return True + for decorator in node.decorator_list: + rendered = ast.unparse(decorator) + attribute = rendered.split("(")[0].split(".")[-1] + if any(marker in rendered for marker in _INDIRECT_DECORATORS): + return True + if "." in rendered and attribute in _ENTRY_POINT_DECORATORS: + return True + return False + + +def _definitions() -> dict[tuple[str, str], int]: + """Map (relative path, name) to the line the definition starts on.""" + found = {} + for path in sorted(PR_AGENT_SOURCE.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if _is_skipped(node): + continue + found.setdefault( + (path.relative_to(ROOT).as_posix(), node.name), node.lineno + ) + return found + + +def _corpus() -> list[str]: + # This file names every allowlisted definition, so reading it would make + # each of them look referenced. + this_file = Path(__file__).resolve() + texts = [] + for root in SEARCH_ROOTS: + if not root.exists(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix not in SEARCH_SUFFIXES: + continue + if path.resolve() == this_file: + continue + texts.append(path.read_text(encoding="utf-8", errors="ignore")) + return texts + + +def _is_referenced(name: str, corpus: list[str]) -> bool: + occurrence = re.compile(r"\b" + re.escape(name) + r"\b") + definition = re.compile( + r"[ \t]*(?:async[ \t]+)?(?:def|class)[ \t]+" + re.escape(name) + r"\b" + ) + for text in corpus: + for match in occurrence.finditer(text): + line_start = text.rfind("\n", 0, match.start()) + 1 + if definition.match(text, line_start): + continue + return True + return False + + +def test_every_definition_is_reachable_or_allowlisted(): + corpus = _corpus() + unreferenced = { + location + for location in _definitions() + if not _is_referenced(location[1], corpus) + } + allowlisted = set(_ALLOWLIST) + + assert unreferenced <= allowlisted, ( + "definitions in pr_agent/ have no reference in pr_agent/, tests/ or docs/; " + f"remove them or allowlist them: {sorted(unreferenced - allowlisted)}" + ) + assert allowlisted - unreferenced == set(), ( + "allowlist has stale entries (those definitions are now referenced or no " + f"longer present): {sorted(allowlisted - unreferenced)}" + ) From 5f23ee996af9d8be557a1b8df9a29c317b32b9a9 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:55:24 +0800 Subject: [PATCH 177/238] chore(lambda): bump Python to v3.14 base and mangum to v0.22.0 (#3505) --- docker/Dockerfile | 1 + docker/Dockerfile.lambda | 10 ++-- pyproject.toml | 6 ++- .../test_ci_python_version_coverage.py | 53 ++++++++++++++++++- uv.lock | 8 +-- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index feab1e3510..6c4eb37425 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -65,6 +65,7 @@ COPY tests tests COPY .github/workflows/publish.yml .github/workflows/publish.yml COPY .github/workflows/build-and-test.yaml .github/workflows/build-and-test.yaml COPY docker/Dockerfile docker/Dockerfile +COPY docker/Dockerfile.lambda docker/Dockerfile.lambda COPY docker/mosaico docker/mosaico FROM base AS cli diff --git a/docker/Dockerfile.lambda b/docker/Dockerfile.lambda index d98dfbb2ac..b2100ebf70 100644 --- a/docker/Dockerfile.lambda +++ b/docker/Dockerfile.lambda @@ -1,4 +1,8 @@ -FROM public.ecr.aws/lambda/python:3.12 AS base +# Keep :3.14 floating: AWS publishes no CPython patch tags for Lambda +# bases, only the minor tag and per-build dated tags that rotate almost +# daily. Chasing dated tags adds bump churn for no real benefit; minor +# alignment with docker/Dockerfile is enforced by the CI version guard. +FROM public.ecr.aws/lambda/python:3.14 AS base COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ RUN dnf update -y && \ @@ -10,8 +14,8 @@ RUN dnf update -y && \ ENV UV_COMPILE_BYTECODE=1 UV_PROJECT_ENVIRONMENT=/opt/venv WORKDIR ${LAMBDA_TASK_ROOT} COPY pyproject.toml uv.lock ./ -RUN uv sync --frozen --no-install-project --no-dev --extra lambda --python /var/lang/bin/python3.12 -ENV PYTHONPATH="/opt/venv/lib/python3.12/site-packages:${LAMBDA_TASK_ROOT}" +RUN uv sync --frozen --no-install-project --no-dev --extra lambda --python /var/lang/bin/python3.14 +ENV PYTHONPATH="/opt/venv/lib/python3.14/site-packages:${LAMBDA_TASK_ROOT}" COPY pr_agent/ ${LAMBDA_TASK_ROOT}/pr_agent/ RUN --mount=type=bind,source=docs/docs,target=/tmp/help-docs-source \ source_count="$(find /tmp/help-docs-source -type f -name '*.md' | wc -l)" && \ diff --git a/pyproject.toml b/pyproject.toml index 7f7051fde0..eed98fd15b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,11 @@ include = [ [project.optional-dependencies] # AWS Lambda deployment (docker/Dockerfile.lambda). -lambda = ["mangum==0.17.0"] +# Keep mangum >=0.21.0 for Python 3.14: older releases call +# asyncio.get_event_loop() with no fallback, which raises RuntimeError +# when no loop is set. Skip 0.20.0, whose 3.14 support was reverted +# upstream; 0.22.0 is end-to-end tested against a real Lambda runtime. +lambda = ["mangum==0.22.0"] # OTLP/gRPC transport for pr-agent telemetry (OTEL.OTLP_PROTOCOL = "grpc"). # The default OTLP/HTTP transport needs no extra: its exporter is a base dependency. otel-grpc = ["opentelemetry-exporter-otlp-proto-grpc==1.43.0"] diff --git a/tests/unittest/test_ci_python_version_coverage.py b/tests/unittest/test_ci_python_version_coverage.py index a760121e3b..ab7d6de2dd 100644 --- a/tests/unittest/test_ci_python_version_coverage.py +++ b/tests/unittest/test_ci_python_version_coverage.py @@ -7,8 +7,13 @@ did not carry went declared and never tested (#3187). The base has since moved to 3.14 (#3294), which is why both declared minors now need native jobs. +Keep docker/Dockerfile.lambda on the same Python minor: #3294 left the +Lambda image behind on 3.12 while the main Dockerfile moved to 3.14. The sync +test below pins the two bases together so a one-file bump cannot slip +through silently again. + These tests assert the repository's CI configuration, not pr_agent logic, hence a -file of their own. They read the checked-in workflow and Dockerfile, which the +file of their own. They read the checked-in workflow and Dockerfiles, which the `test` docker target copies into the image alongside publish.yml so the suite carries the same signal in CI as it does locally. No network, no docker. """ @@ -22,12 +27,22 @@ BUILD_AND_TEST_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "build-and-test.yaml" PYPROJECT = REPOSITORY_ROOT / "pyproject.toml" DOCKERFILE = REPOSITORY_ROOT / "docker" / "Dockerfile" +LAMBDA_DOCKERFILE = REPOSITORY_ROOT / "docker" / "Dockerfile.lambda" # "google-cloud-storage==3.12.0; python_version >= '3.13'" -> "3.13" PIN_MARKER_VERSION = re.compile(r"""python_version\s*>=\s*['"](\d+\.\d+)""") REQUIRES_PYTHON_MINIMUM = re.compile(r">=\s*(\d+\.\d+)") # Only the base stage names an image; every other stage is "FROM base AS ...". DOCKER_BASE_VERSION = re.compile(r"^FROM python:(\d+\.\d+)", re.MULTILINE) +LAMBDA_BASE_VERSION = re.compile(r"^FROM public\.ecr\.aws/lambda/python:(\d+\.\d+)", re.MULTILINE) +LAMBDA_INTERPRETER_VERSION = re.compile( + r"^RUN uv sync\b.*--python /var/lang/bin/python(\d+\.\d+)(?:\s|$)", + re.MULTILINE, +) +LAMBDA_SITE_PACKAGES_VERSION = re.compile( + r'^ENV PYTHONPATH=["\']?/opt/venv/lib/python(\d+\.\d+)/site-packages', + re.MULTILINE, +) UV_PYTHON_FLAG = re.compile(r"--python[ =](\d+\.\d+)") @@ -77,3 +92,39 @@ def test_every_declared_python_version_is_tested_in_ci() -> None: f"exercises {sorted(tested)}. Add a job for {sorted(declared - tested)}, or narrow " f"requires-python and drop the now-unreachable dependency pins." ) + + +def test_lambda_base_matches_main_base() -> None: + """docker/Dockerfile.lambda must track docker/Dockerfile's Python minor. + + #3294 moved the main image to 3.14 while the Lambda image stayed on + 3.12, and nothing failed because no test read the Lambda Dockerfile. + Only the minor is asserted: AWS publishes no CPython patch tags for + Lambda bases, so the Lambda Dockerfile intentionally floats on the + minor tag instead of chasing per-build dated tags. The uv interpreter + path and site-packages PYTHONPATH embed the same minor and are + asserted too, because nothing builds this image in CI before publish. + """ + main = DOCKER_BASE_VERSION.search(DOCKERFILE.read_text(encoding="utf-8")) + lambda_dockerfile = LAMBDA_DOCKERFILE.read_text(encoding="utf-8") + lambda_base = LAMBDA_BASE_VERSION.search(lambda_dockerfile) + interpreter = LAMBDA_INTERPRETER_VERSION.search(lambda_dockerfile) + site_packages = LAMBDA_SITE_PACKAGES_VERSION.search(lambda_dockerfile) + assert main is not None, f"could not parse a base Python version out of {DOCKERFILE}" + assert lambda_base is not None, f"could not parse a base Python version out of {LAMBDA_DOCKERFILE}" + assert lambda_base.group(1) == main.group(1), ( + f"docker/Dockerfile is on Python {main.group(1)} but docker/Dockerfile.lambda is on " + f"Python {lambda_base.group(1)}. Bump the Lambda base tag, interpreter path, and " + f"site-packages PYTHONPATH together." + ) + assert interpreter is not None, ( + f"could not parse the uv --python interpreter version out of {LAMBDA_DOCKERFILE}" + ) + assert site_packages is not None, ( + f"could not parse the site-packages PYTHONPATH version out of {LAMBDA_DOCKERFILE}" + ) + assert interpreter.group(1) == lambda_base.group(1) == site_packages.group(1), ( + f"docker/Dockerfile.lambda mixes Python versions: base image {lambda_base.group(1)}, " + f"interpreter {interpreter.group(1)}, site-packages {site_packages.group(1)}. Bump the " + f"base tag, interpreter path, and site-packages PYTHONPATH together." + ) diff --git a/uv.lock b/uv.lock index 060690af3f..acd29ee9c8 100644 --- a/uv.lock +++ b/uv.lock @@ -1816,14 +1816,14 @@ wheels = [ [[package]] name = "mangum" -version = "0.17.0" +version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/3d/c9664d56ad6b32d7c7ebf82a77cacc290ebed033f96adf0c98a9114ace4e/mangum-0.17.0.tar.gz", hash = "sha256:5b4e26375e12eed051687670466d17968f8b74beecaca432edd4eb4127f78509", size = 14222, upload-time = "2022-11-27T11:21:42.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/c8/1579c705908d02a4526a748e8d0f0419151d4498c59033840f5df3a14822/mangum-0.22.0.tar.gz", hash = "sha256:701936891aaed8ed66479b871c2bda87ba288fd01c7d5dd6ba6c8e9ba1c239d1", size = 118290, upload-time = "2026-08-22T12:54:48.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/3d/aa091b88f5fe2c4d73fd42831affc4aab26cdbdf0bab9925e1cabf297aaa/mangum-0.17.0-py3-none-any.whl", hash = "sha256:f00be705605bc4793958df62e4d249abf58d254c39d90bb410d069570206f4a2", size = 17191, upload-time = "2022-11-27T11:21:40.261Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a2/21ee5d457ae79a2587ff0d4652990670b7cac84ba0eaab6e6865a3bd2515/mangum-0.22.0-py3-none-any.whl", hash = "sha256:a0595e3cc7a8091b22d8b3997bab5b2ad6aae7a5e40865e19c2015f5c959a93b", size = 17143, upload-time = "2026-08-22T12:54:46.924Z" }, ] [[package]] @@ -2565,7 +2565,7 @@ requires-dist = [ { name = "langfuse", specifier = "==3.14.5" }, { name = "litellm", specifier = "==1.101.0" }, { name = "loguru", specifier = "==0.7.3" }, - { name = "mangum", marker = "extra == 'lambda'", specifier = "==0.17.0" }, + { name = "mangum", marker = "extra == 'lambda'", specifier = "==0.22.0" }, { name = "msrest", specifier = "==0.7.1" }, { name = "openai", specifier = ">=1.55.3" }, { name = "opentelemetry-api", specifier = "==1.43.0" }, From cbea282f6ac61d8b682f9b3776d75e33d5604143 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:55:54 +0800 Subject: [PATCH 178/238] chore(ci): migrate Release Drafter v7 config (#3515) --- .github/release-drafter.yml | 95 +++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 80d93c052b..124d9b353f 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -10,30 +10,64 @@ template: | categories: - title: '🚀 Features' - labels: - - feature - - enhancement + when: + labels: + - feature + - enhancement - title: '🐛 Bug Fixes' - labels: - - bug - - 'Bug fix' - - 'Bug fix with tests' + when: + labels: + - bug + - 'Bug fix' + - 'Bug fix with tests' - title: '⚡ Performance' - labels: - - performance + when: + labels: + - performance - title: '📚 Documentation' - labels: - - documentation - - 'Includes Documentation' + when: + labels: + - documentation + - 'Includes Documentation' - title: '🧹 Maintenance' - labels: - - chore - - Refactoring - - Configuration - - 'Configuration changes' + when: + labels: + - chore + - Refactoring + - Configuration + - 'Configuration changes' - title: '⬆️ Dependencies' - labels: - - dependencies + when: + labels: + - dependencies + - type: version-resolver + semver-increment: major + when: + labels: + - breaking + - type: version-resolver + semver-increment: minor + when: + labels: + - feature + - enhancement + - type: version-resolver + semver-increment: patch + when: + labels: + - bug + - 'Bug fix' + - 'Bug fix with tests' + - documentation + - 'Includes Documentation' + - chore + - Refactoring + - Configuration + - 'Configuration changes' + - performance + - dependencies + - type: version-resolver + semver-increment: patch change-template: '- $TITLE by @$AUTHOR in #$NUMBER' change-title-escapes: '\<*_&' @@ -64,26 +98,3 @@ autolabeler: - label: breaking title: - '/^[a-z]+(\(.+\))?!:/i' - -version-resolver: - major: - labels: - - breaking - minor: - labels: - - feature - - enhancement - patch: - labels: - - bug - - 'Bug fix' - - 'Bug fix with tests' - - documentation - - 'Includes Documentation' - - chore - - Refactoring - - Configuration - - 'Configuration changes' - - performance - - dependencies - default: patch From 9c9dc60a7f2f0ab83888ebc2cc6ee2d39ff1f8b1 Mon Sep 17 00:00:00 2001 From: Sanchar panthi <143952019+Sanchar127@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:17:39 +0545 Subject: [PATCH 179/238] fix(github-action): propagate command failure status (#3486) --- pr_agent/servers/github_action_runner.py | 65 ++++++++++++++----- .../test_github_action_runner_core.py | 52 +++++++++++++++ 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index 81402ab805..cf7bd71137 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -1,7 +1,9 @@ import asyncio import json import os -from typing import Union +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Optional, Union import dynaconf @@ -23,6 +25,14 @@ from pr_agent.tools.pr_reviewer import PRReviewer +@dataclass +class _ActionStatus: + failed: bool = False + + +_action_status: ContextVar[Optional[_ActionStatus]] = ContextVar("pr_agent_action_status", default=None) + + def is_true(value: Union[str, bool]) -> bool: if isinstance(value, bool): return value @@ -58,6 +68,14 @@ def get_list_setting_or_env(key, fallback=None): return [value] +async def _handle_request(url, body, notify=None): + result = await PRAgent().handle_request(url, body, notify=notify) + if result is False: + status = _action_status.get() + if status is not None: + status.failed = True + + async def _run_auto_tool(tool_class, pr_url): """Run a direct auto tool while preserving GitHub Action failure semantics.""" try: @@ -66,7 +84,6 @@ async def _run_auto_tool(tool_class, pr_url): publish_incomplete_github_files_comment(pr_url) raise - async def _run_review_commands(event_payload): action = event_payload.get("action") if action != "submitted": @@ -136,7 +153,7 @@ async def _run_review_commands(event_payload): get_settings().pr_description.final_update_message = False get_logger().info(f"Running review commands: {review_commands}") for command in review_commands: - await PRAgent().handle_request(pr_url, command) + await _handle_request(pr_url, command) async def run_action(): @@ -273,7 +290,7 @@ async def run_action(): get_settings().pr_description.final_update_message = False get_logger().info(f"Running push commands: {push_commands}") for command in push_commands: - await PRAgent().handle_request(pr_url, command) + await _handle_request(pr_url, command) return if action in pr_actions: pr_url = event_payload.get("pull_request", {}).get("url") @@ -306,7 +323,7 @@ async def run_action(): # Handle submitted pull request review event elif GITHUB_EVENT_NAME == "pull_request_review": - await _run_review_commands(event_payload) + return await _run_review_commands(event_payload) # Handle issue comment event elif GITHUB_EVENT_NAME == "issue_comment" or GITHUB_EVENT_NAME == "pull_request_review_comment": @@ -356,13 +373,15 @@ async def run_action(): provider = get_git_provider()(pr_url=url) if is_pr: _inject_artifact_context() - await PRAgent().handle_request( - url, body, notify=lambda: provider.add_eyes_reaction( + await _handle_request( + url, + body, + notify=lambda: provider.add_eyes_reaction( comment_id, disable_eyes=disable_eyes - ) + ), ) else: - await PRAgent().handle_request(url, body) + await _handle_request(url, body) # Handle workflow_run event (triggered after another workflow completes, e.g. after a terraform plan) elif GITHUB_EVENT_NAME == "workflow_run": @@ -464,14 +483,30 @@ async def _run_action_and_drain(): Wrapping here rather than at the end of run_action() covers its many early returns too, and keeps run_action() itself free of teardown concerns. """ + status = _ActionStatus() + token = _action_status.set(status) + try: - return await run_action() + await run_action() finally: - if litellm_callbacks_registered(): - await drain_litellm_callbacks( - get_settings().litellm.get("callback_timeout_seconds", DEFAULT_CALLBACK_TIMEOUT_SECONDS) - ) + try: + if litellm_callbacks_registered(): + await drain_litellm_callbacks( + get_settings().litellm.get( + "callback_timeout_seconds", + DEFAULT_CALLBACK_TIMEOUT_SECONDS, + ) + ) + finally: + _action_status.reset(token) + if status.failed: + raise SystemExit(1) -if __name__ == '__main__': + +def main(): asyncio.run(_run_action_and_drain()) + + +if __name__ == '__main__': + main() diff --git a/tests/unittest/test_github_action_runner_core.py b/tests/unittest/test_github_action_runner_core.py index 638736d35a..761179af31 100644 --- a/tests/unittest/test_github_action_runner_core.py +++ b/tests/unittest/test_github_action_runner_core.py @@ -525,6 +525,31 @@ async def test_synchronize_event_triggers_push_commands(monkeypatch, tmp_path, r ("https://api.github.com/repos/org/repo/pulls/1", "/improve"), ] +def test_action_exits_nonzero_when_command_fails_and_continues( + monkeypatch, tmp_path, restore_github_settings +): + handled = [] + _patch_synchronize_deps(monkeypatch, handled, ["/review", "/improve"]) + + class FakeAgent: + async def handle_request(self, url, body, notify=None): + handled.append((url, body)) + return body != "/review" + + monkeypatch.setattr(github_action_runner, "PRAgent", FakeAgent) + monkeypatch.setattr(github_action_runner, "litellm_callbacks_registered", lambda: False) + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_synchronize_event(tmp_path))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + with pytest.raises(SystemExit) as exc_info: + github_action_runner.main() + + assert exc_info.value.code == 1 + assert handled == [ + ("https://api.github.com/repos/org/repo/pulls/1", "/review"), + ("https://api.github.com/repos/org/repo/pulls/1", "/improve"), + ] @pytest.mark.asyncio async def test_synchronize_skips_when_push_trigger_disabled(monkeypatch, tmp_path, restore_github_settings): @@ -635,6 +660,22 @@ async def test_issue_comment_from_user_is_processed(monkeypatch, tmp_path, resto assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/review")] +@pytest.mark.asyncio +async def test_issue_comment_on_plain_issue_is_dispatched(monkeypatch, tmp_path, restore_github_settings): + handled = [] + _patch_issue_comment_deps(monkeypatch, handled) + monkeypatch.setenv("GITHUB_EVENT_NAME", "issue_comment") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_plain_issue_comment_event(tmp_path))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + await github_action_runner.run_action() + + assert handled == [ + ( + "https://api.github.com/repos/org/repo/issues/1", + "/ask what is this issue about?", + ) + ] def _write_workflow_run_event(tmp_path, originating_event="pull_request", pull_requests=None, conclusion="success"): if pull_requests is None: @@ -784,6 +825,17 @@ def _write_issue_comment_event_with_body(tmp_path, body): })) return event_path +def _write_plain_issue_comment_event(tmp_path): + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps({ + "action": "created", + "comment": {"body": "/ask what is this issue about?", "id": 123}, + "issue": { + "url": "https://api.github.com/repos/org/repo/issues/1", + }, + "sender": {"type": "User"}, + })) + return event_path @pytest.mark.asyncio async def test_issue_comment_body_reaches_the_agent_with_its_case_preserved( From 6f55d00fcbc2b9573992b9f54100dbfb428e7bd9 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:32:44 +0800 Subject: [PATCH 180/238] chore(ci): add Python 3.14 to the native unit-test matrix (#3514) --- .github/workflows/build-and-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index f23cdd96c1..3366bdec11 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -106,7 +106,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12", "3.13"] + python-version: ["3.12", "3.13", "3.14"] steps: - id: checkout From 34bb419a96b932a1f4f8eb938827860744fc263b Mon Sep 17 00:00:00 2001 From: Rajin Khan Date: Sun, 20 Sep 2026 03:10:34 +0600 Subject: [PATCH 181/238] fix(litellm): support adaptive thinking for profile ARNs (#3497) --- docs/docs/usage-guide/changing_a_model.md | 36 ++-- .../usage-guide/configuration_reference.md | 1 + .../algo/ai_handlers/litellm_ai_handler.py | 71 +++++-- pr_agent/settings/configuration.toml | 5 + tests/unittest/test_config_dead_keys.py | 9 +- tests/unittest/test_config_reference.py | 2 +- .../test_litellm_claude_adaptive_thinking.py | 191 +++++++++++++++++- tests/unittest/test_litellm_run_details.py | 1 + 8 files changed, 266 insertions(+), 50 deletions(-) diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 18a5ac7fed..dda35905ab 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -406,34 +406,28 @@ The `litellm.model_id` parameter applies only to classic `bedrock/` calls made t Claude Sonnet 5 on Bedrock is invoked through an inference profile rather than a direct foundation-model id. When that profile is an application inference profile, its ARN is an -opaque value that carries no model name. Thinking configuration in PR-Agent is gated on -recognizing the model, so the opaque ARN can never match: `enable_claude_adaptive_thinking` -requires a recognized Claude 5 model name in the id, and `enable_claude_extended_thinking` -requires exact membership in `claude_extended_thinking_models`. PR-Agent logs a warning in -that case, so the unconfigured state is no longer silent. +opaque value that carries no model name. Add that ARN to +`claude_adaptive_thinking_models_override` so PR-Agent and LiteLLM both treat it as an +adaptive-thinking model: -Address the model by name and pass the profile ARN through `litellm.model_id`, which is what -the invocation actually uses: +Use the ARN as the model id and repeat that exact value in the override: ```toml [config] # in configuration.toml -model = "bedrock/converse/eu.anthropic.claude-sonnet-5" -fallback_models = ["bedrock/converse/eu.anthropic.claude-sonnet-5"] -enable_claude_adaptive_thinking = true # requires a recognizable claude 5 model name in `model` - -[litellm] -model_id = "arn:aws:bedrock:eu-central-1::application-inference-profile/" +model = "bedrock/converse/arn:aws:bedrock:eu-central-1::application-inference-profile/" +enable_claude_adaptive_thinking = true +claude_adaptive_thinking_models_override = [ + "bedrock/converse/arn:aws:bedrock:eu-central-1::application-inference-profile/" +] ``` -Cost attribution is preserved through the application inference profile, and because `model` -is the named id, the adaptive-thinking payload is applied and kept intact. +The override is additive, so named Claude models in the same fallback chain continue to use +built-in detection. PR-Agent also registers each override with LiteLLM, preventing LiteLLM from +converting the adaptive payload to the legacy `budget_tokens` shape that Bedrock rejects. -Two caveats. First, ARNs only fail the detection when the suffix is opaque: an ARN that -embeds the model family, for example `...:inference-profile/us.anthropic.claude-sonnet-5`, -normalises to a string the adaptive regex does match. The miss is specific to application -inference profiles with an opaque hex suffix. Second, `litellm.model_id` is a single global -value applied to every model whose id contains `bedrock/`, so this configuration cannot point -different models at different profiles within one fallback chain without per-call handling. +ARNs only need the override when the suffix is opaque. An ARN that embeds the model family, +for example `...:inference-profile/us.anthropic.claude-sonnet-5`, normalises to a string the +adaptive regex already matches. #### Using a Custom VPC Endpoint (PrivateLink) diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md index 0321c7027a..87e2d73972 100644 --- a/docs/docs/usage-guide/configuration_reference.md +++ b/docs/docs/usage-guide/configuration_reference.md @@ -125,6 +125,7 @@ to-do list. | `extended_thinking_budget_tokens` | 2048 | | | `extended_thinking_max_output_tokens` | 4096 | | | `enable_claude_adaptive_thinking` | false | Adaptive thinking for Claude Opus 4.7/4.8 and Claude 5 models. When enabled, these models receive thinking={"type": "adaptive"} and an output_config effort. Do not add adaptive-only models to claude_extended_thinking_models_override; when both features are enabled, adaptive thinking takes precedence. | +| `claude_adaptive_thinking_models_override` | [] | Optional: list model ids to additionally treat as adaptive-only Claude models. Add opaque Bedrock application inference profile ARNs here while keeping built-in detection for named models. When adaptive thinking is enabled, use the exact request model ids so LiteLLM keeps the adaptive payload instead of converting it to the legacy budget_tokens shape. | | `claude_extended_thinking_models_override` | [] | Optional: override the built-in list of Claude models that receive the extended-thinking payload. When non-empty, this list fully replaces the built-in defaults (see CLAUDE_EXTENDED_THINKING_MODELS in pr_agent/algo/__init__.py). Leave empty to use the defaults. | | `extract_issue_from_branch` | true | Extract issue number from PR source branch name (e.g. feature/1-auth-google -> issue #1). When true, branch-derived issue URLs are merged with tickets from the PR description for compliance. Set to false to restore description-only behaviour. Note: Branch-name extraction is GitHub-only for now; other providers planned for later. | | `branch_issue_regex` | "" | Optional: custom regex with exactly one capturing group for the issue number (validated at runtime; falls back to default if missing). If empty, uses default pattern: first 1-6 digits at start of branch or after a slash, followed by hyphen or end (e.g. feature/1-test, 123-fix). GitHub only; other providers planned for later. | diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 006db88220..a244f429e4 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -2220,25 +2220,31 @@ def __init__(self): ] # Models that support extended thinking (config override replaces the built-in list when non-empty) - override = get_settings().config.get("claude_extended_thinking_models_override", []) or [] - if override and not isinstance(override, list): - get_logger().warning( - "Invalid claude_extended_thinking_models_override in config; expected a list of model names. " - "Falling back to the built-in Claude extended-thinking model list." - ) - override = [] - elif override and not all(isinstance(model, str) and model.strip() for model in override): - get_logger().warning( - "Invalid claude_extended_thinking_models_override in config; " - "expected a list of model name strings. " - "Falling back to the built-in Claude extended-thinking model list." - ) - override = [] - # Store stripped names so exact-match checks against the model succeed even when the config - # entries contain surrounding whitespace (validation above already used model.strip()). - self.claude_extended_thinking_models = ( - [model.strip() for model in override] if override else CLAUDE_EXTENDED_THINKING_MODELS + override = self._validated_model_name_list("claude_extended_thinking_models_override") + self.claude_extended_thinking_models = override or CLAUDE_EXTENDED_THINKING_MODELS + + # Treat configured model ids as additional adaptive-only models. Add opaque Bedrock application + # inference profile ARNs while preserving built-in detection for named models. + self.claude_adaptive_thinking_models_override = self._validated_model_name_list( + "claude_adaptive_thinking_models_override" ) + bedrock_overrides = [ + model + for model in self.claude_adaptive_thinking_models_override + if model.startswith("bedrock/") or re.match(r"^arn:[^:]+:bedrock:", model) + ] + if ( + bedrock_overrides + and self._claude_thinking_controls["enable_claude_adaptive_thinking"] + ): + litellm.register_model({ + model: { + "litellm_provider": "bedrock", + "mode": "chat", + "supports_adaptive_thinking": True, + } + for model in bedrock_overrides + }) # Models that require streaming self.streaming_required_models = STREAMING_REQUIRED_MODELS @@ -3680,7 +3686,7 @@ def _coerce_token_value(value) -> int: def _claude_thinking_mode(self, model: str) -> str | None: """Return the thinking mode selected by request construction for this model.""" - adaptive_model = self._is_claude_adaptive_thinking_model(model) + adaptive_model = self._model_uses_adaptive_thinking(model) if adaptive_model and self._claude_thinking_controls["enable_claude_adaptive_thinking"]: return "adaptive" if ( @@ -3853,6 +3859,22 @@ def _configure_claude_extended_thinking(self, model: str, kwargs: dict) -> dict: return kwargs + @staticmethod + def _validated_model_name_list(setting_name: str) -> list[str]: + """Return a stripped config list of model names, or an empty list when malformed.""" + value = get_settings().config.get(setting_name, []) or [] + if not value: + return [] + if not isinstance(value, list) or not all( + isinstance(model, str) and model.strip() for model in value + ): + get_logger().warning( + f"Invalid {setting_name} in config; expected a list of model name strings. " + "Ignoring it and using the built-in Claude thinking model detection." + ) + return [] + return [model.strip() for model in value] + @staticmethod def _is_claude_adaptive_thinking_model(model: str) -> bool: """Return whether a Claude model requires the adaptive thinking API.""" @@ -3862,6 +3884,13 @@ def _is_claude_adaptive_thinking_model(model: str) -> bool: normalized_model, ) is not None + def _model_uses_adaptive_thinking(self, model: str) -> bool: + """Return whether a model should receive the adaptive-thinking payload.""" + return ( + isinstance(model, str) + and model.strip() in self.claude_adaptive_thinking_models_override + ) or self._is_claude_adaptive_thinking_model(model) + def _configure_claude_adaptive_thinking(self, model: str, kwargs: dict) -> dict: """Configure thinking for Claude models that reject token budgets.""" kwargs["thinking"] = {"type": "adaptive"} @@ -4264,8 +4293,8 @@ async def _chat_completion_with_retry( ) if "arn:aws:bedrock:" in model: message += ( - " For a Bedrock inference profile, address the model by name and pass " - "the ARN with litellm.model_id." + " For a Bedrock inference profile, add the ARN to " + "config.claude_adaptive_thinking_models_override." ) get_logger().warning(message) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 916387953d..80de61aa54 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -117,6 +117,11 @@ extended_thinking_max_output_tokens = 4096 # Do not add adaptive-only models to claude_extended_thinking_models_override; # when both features are enabled, adaptive thinking takes precedence. enable_claude_adaptive_thinking = false +# Optional: list model ids to additionally treat as adaptive-only Claude models. Add opaque Bedrock +# application inference profile ARNs here while keeping built-in detection for named models. +# When adaptive thinking is enabled, use the exact request model ids so LiteLLM keeps the adaptive +# payload instead of converting it to the legacy budget_tokens shape. +claude_adaptive_thinking_models_override = [] # Optional: override the built-in list of Claude models that receive the extended-thinking payload. # When non-empty, this list fully replaces the built-in defaults (see CLAUDE_EXTENDED_THINKING_MODELS # in pr_agent/algo/__init__.py). Leave empty to use the defaults. diff --git a/tests/unittest/test_config_dead_keys.py b/tests/unittest/test_config_dead_keys.py index ba37c7f67c..1e2677f277 100644 --- a/tests/unittest/test_config_dead_keys.py +++ b/tests/unittest/test_config_dead_keys.py @@ -183,10 +183,13 @@ def _key_is_read(section: str, key: str, sources: str) -> bool: sources, ): return True - # 5. helper indirection for the [config] section: _read_bool_setting("key") - # and get_reaction_setting("key") both read get_settings().config.get(key). + # 5. Detect helpers that read a [config] key through a variable name. if section == "config": - for helper in ("_read_bool_setting", "get_reaction_setting"): + for helper in ( + "_read_bool_setting", + "get_reaction_setting", + "_validated_model_name_list", + ): if re.search( r"\b" + re.escape(helper) + r"\s*\(\s*['\"]" + key_name + r"['\"]", sources, diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py index f530a103dc..7434d2e6e9 100644 --- a/tests/unittest/test_config_reference.py +++ b/tests/unittest/test_config_reference.py @@ -23,7 +23,7 @@ def _documented_keys(text: str) -> Counter: def test_config_reference_covers_every_active_key(): keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) - assert sum(keys.values()) == 267 + assert sum(keys.values()) == 268 assert { "model", diff --git a/tests/unittest/test_litellm_claude_adaptive_thinking.py b/tests/unittest/test_litellm_claude_adaptive_thinking.py index fdcf13a230..cf51ff8d7f 100644 --- a/tests/unittest/test_litellm_claude_adaptive_thinking.py +++ b/tests/unittest/test_litellm_claude_adaptive_thinking.py @@ -1,3 +1,4 @@ +import copy import os from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -5,6 +6,7 @@ import litellm import openai import pytest +from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig import pr_agent.algo.ai_handlers.litellm_ai_handler as litellm_handler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler @@ -28,6 +30,7 @@ def _restore_litellm_globals(): AWS_USE_IMDS is set, os.environ; snapshot and restore both, and drop AWS_USE_IMDS so the AWS credential path never runs in these tests.""" saved = (litellm.api_key, getattr(litellm, "openai_key", None), openai.api_key) + saved_model_cost = copy.deepcopy(litellm.model_cost) saved_env = {name: os.environ.get(name) for name in _HANDLER_ENV_VARS} os.environ.pop("AWS_USE_IMDS", None) try: @@ -36,6 +39,8 @@ def _restore_litellm_globals(): litellm.api_key = saved[0] litellm.openai_key = saved[1] openai.api_key = saved[2] + litellm.model_cost.clear() + litellm.model_cost.update(saved_model_cost) for name, value in saved_env.items(): if value is None: os.environ.pop(name, None) @@ -49,6 +54,8 @@ def _settings( extended_enabled=False, extended_budget_tokens=2048, extended_max_output_tokens=4096, + adaptive_override=None, + custom_llm_provider="", ): flags = { "enable_claude_adaptive_thinking": enabled, @@ -56,6 +63,8 @@ def _settings( "extended_thinking_budget_tokens": extended_budget_tokens, "extended_thinking_max_output_tokens": extended_max_output_tokens, } + if adaptive_override is not None: + flags["claude_adaptive_thinking_models_override"] = adaptive_override config = SimpleNamespace( reasoning_effort=reasoning_effort, ai_timeout=120, @@ -73,7 +82,10 @@ def _settings( } return SimpleNamespace( config=config, - litellm=SimpleNamespace(get=lambda key, default=None: default), + litellm=SimpleNamespace( + custom_llm_provider=custom_llm_provider, + get=lambda key, default=None: default, + ), get=lambda key, default=None: aws.get(key, default), ) @@ -87,14 +99,19 @@ def _response(): async def _run_completion(monkeypatch, model, reasoning_effort="medium", enabled=False, - extended_enabled=False, extended_override=None): + extended_enabled=False, extended_override=None, adaptive_override=None): # An ambient selector would be refused before the request credentials are read. for variable in ("AWS_PROFILE_NAME", "AWS_ROLE_NAME"): monkeypatch.delenv(variable, raising=False) monkeypatch.setattr( litellm_handler, "get_settings", - lambda: _settings(reasoning_effort, enabled, extended_enabled), + lambda: _settings( + reasoning_effort, + enabled, + extended_enabled, + adaptive_override=adaptive_override, + ), ) with patch( "pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", @@ -307,7 +324,173 @@ async def test_opaque_arn_with_adaptive_enabled_warns_and_skips_payload(monkeypa assert "output_config" not in kwargs logger.warning.assert_called_once() assert "abc123def456" in logger.warning.call_args.args[0] - assert "litellm.model_id" in logger.warning.call_args.args[0] + assert "claude_adaptive_thinking_models_override" in logger.warning.call_args.args[0] + + +_PROFILE_ARN = ( + "bedrock/converse/arn:aws:bedrock:eu-central-1:000000000000:" + "application-inference-profile/abc123def456" +) +_RAW_PROFILE_ARN = ( + "arn:aws:bedrock:eu-central-1:000000000000:" + "application-inference-profile/abc123def456" +) +_UNLISTED_PROFILE_ARN = ( + "bedrock/converse/arn:aws:bedrock:eu-central-1:000000000000:" + "application-inference-profile/unlisted789" +) + + +def test_opaque_model_override_registers_adaptive_support(monkeypatch): + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _settings(enabled=True, adaptive_override=[f" {_PROFILE_ARN} "]), + ) + register_model = MagicMock() + monkeypatch.setattr(litellm, "register_model", register_model) + + handler = LiteLLMAIHandler() + + assert handler.claude_adaptive_thinking_models_override == [_PROFILE_ARN] + assert handler._model_uses_adaptive_thinking(_PROFILE_ARN) is True + register_model.assert_called_once_with({ + _PROFILE_ARN: { + "litellm_provider": "bedrock", + "mode": "chat", + "supports_adaptive_thinking": True, + } + }) + + +def test_registered_opaque_model_keeps_adaptive_payload(monkeypatch): + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _settings(enabled=True, adaptive_override=[_PROFILE_ARN]), + ) + config = AmazonConverseConfig() + params = {"thinking": {"type": "adaptive"}} + + before = config.map_openai_params(params.copy(), {}, _PROFILE_ARN, False) + LiteLLMAIHandler() + after = config.map_openai_params(params.copy(), {}, _PROFILE_ARN, False) + unlisted = config.map_openai_params(params.copy(), {}, _UNLISTED_PROFILE_ARN, False) + + assert before["thinking"] == {"type": "enabled", "budget_tokens": 2048} + assert after["thinking"] == {"type": "adaptive"} + assert unlisted["thinking"] == {"type": "enabled", "budget_tokens": 2048} + + +def test_registered_raw_bedrock_arn_keeps_adaptive_payload(monkeypatch): + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _settings(enabled=True, adaptive_override=[_RAW_PROFILE_ARN]), + ) + config = AmazonConverseConfig() + params = {"thinking": {"type": "adaptive"}} + + before = config.map_openai_params(params.copy(), {}, _RAW_PROFILE_ARN, False) + LiteLLMAIHandler() + after = config.map_openai_params(params.copy(), {}, _RAW_PROFILE_ARN, False) + + assert before["thinking"] == {"type": "enabled", "budget_tokens": 2048} + assert after["thinking"] == {"type": "adaptive"} + + +@pytest.mark.asyncio +async def test_raw_bedrock_arn_with_custom_provider_receives_adaptive_payload(monkeypatch): + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _settings( + enabled=True, + adaptive_override=[_RAW_PROFILE_ARN], + custom_llm_provider="bedrock", + ), + ) + with patch( + "pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion", + new_callable=AsyncMock, + ) as completion: + completion.return_value = _response() + handler = LiteLLMAIHandler() + await handler.chat_completion(model=_RAW_PROFILE_ARN, system="sys", user="usr") + + kwargs = completion.call_args.kwargs + assert kwargs["model"] == _RAW_PROFILE_ARN + assert kwargs["custom_llm_provider"] == "bedrock" + assert kwargs["thinking"] == {"type": "adaptive"} + + +def test_named_override_keeps_litellm_model_info(monkeypatch): + named = "anthropic/claude-sonnet-4-6" + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _settings(enabled=True, adaptive_override=[named, _PROFILE_ARN]), + ) + provider_before = litellm.get_model_info(named)["litellm_provider"] + + handler = LiteLLMAIHandler() + + assert litellm.get_model_info(named)["litellm_provider"] == provider_before + assert handler._model_uses_adaptive_thinking(named) is True + assert _PROFILE_ARN in litellm.model_cost + + +def test_disabled_adaptive_thinking_does_not_register_override(monkeypatch): + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _settings( + enabled=False, + extended_enabled=True, + adaptive_override=[_PROFILE_ARN], + ), + ) + register_model = MagicMock() + monkeypatch.setattr(litellm, "register_model", register_model) + + handler = LiteLLMAIHandler() + handler.claude_extended_thinking_models = [_PROFILE_ARN] + + register_model.assert_not_called() + assert handler._claude_thinking_mode(_PROFILE_ARN) == "unsupported_extended" + + +@pytest.mark.parametrize("bad_override", ["not-a-list", [""], ["ok", 5], [None]]) +def test_malformed_adaptive_override_falls_back_to_builtin_detection(monkeypatch, bad_override): + monkeypatch.setattr( + litellm_handler, + "get_settings", + lambda: _settings(adaptive_override=bad_override), + ) + monkeypatch.setattr(litellm, "register_model", MagicMock()) + + handler = LiteLLMAIHandler() + + assert handler.claude_adaptive_thinking_models_override == [] + assert handler._model_uses_adaptive_thinking(_PROFILE_ARN) is False + assert handler._model_uses_adaptive_thinking("anthropic/claude-opus-4-8") is True + + +@pytest.mark.asyncio +async def test_overridden_opaque_model_receives_adaptive_payload(monkeypatch): + monkeypatch.setattr(litellm, "register_model", MagicMock()) + + kwargs = await _run_completion( + monkeypatch, + _PROFILE_ARN, + reasoning_effort="high", + enabled=True, + adaptive_override=[_PROFILE_ARN], + ) + + assert kwargs["thinking"] == {"type": "adaptive"} + assert kwargs["output_config"] == {"effort": "high"} + assert "temperature" not in kwargs @pytest.mark.asyncio diff --git a/tests/unittest/test_litellm_run_details.py b/tests/unittest/test_litellm_run_details.py index 3d2c38dbe6..1058188979 100644 --- a/tests/unittest/test_litellm_run_details.py +++ b/tests/unittest/test_litellm_run_details.py @@ -188,6 +188,7 @@ def _bare_handler(): handler.repetition_penalty = None handler.add_litellm_callbacks = False handler.claude_extended_thinking_models = [] + handler.claude_adaptive_thinking_models_override = [] handler.no_support_temperature_models = [] handler.additional_reasoning_effort_models = [] handler.user_message_only_models = [] From 3a49d19f496bb6159ccfb93ecdfc0a226ad9d670 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:56:06 +0545 Subject: [PATCH 182/238] test: pin Gemini 3.x ids through the reasoning-effort metadata probe (from #2998) (#3504) --- tests/unittest/test_get_max_tokens.py | 2 ++ .../unittest/test_litellm_reasoning_effort.py | 35 ++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/tests/unittest/test_get_max_tokens.py b/tests/unittest/test_get_max_tokens.py index f0ea03202d..b2a11a09cf 100644 --- a/tests/unittest/test_get_max_tokens.py +++ b/tests/unittest/test_get_max_tokens.py @@ -428,6 +428,8 @@ def test_model_max_tokens_with__limit(self, monkeypatch): "vertex_ai/gemini-3.1-flash", "gemini/gemini-3.1-pro", "vertex_ai/gemini-3.1-pro", + "gemini/gemini-3.1-flash-lite", + "vertex_ai/gemini-3.1-flash-lite", "gemini/gemini-3.1-flash-lite-preview", "vertex_ai/gemini-3.1-flash-lite-preview", "gemini/gemini-3.5-flash", diff --git a/tests/unittest/test_litellm_reasoning_effort.py b/tests/unittest/test_litellm_reasoning_effort.py index 2ae87bb368..7e5e6d1e2d 100644 --- a/tests/unittest/test_litellm_reasoning_effort.py +++ b/tests/unittest/test_litellm_reasoning_effort.py @@ -1008,12 +1008,13 @@ async def test_astra_output_limit(self, monkeypatch): class TestLiteLLMReasoningEffortGemini: - """Gemini 2.5 reasoning_effort handling via litellm's bundled metadata. + """Gemini 2.5/3.x reasoning_effort handling via litellm's bundled metadata. - Gemini 2.5 exposes a thinking budget that LiteLLM maps from reasoning_effort. The - support probe in chat_completion matches bare and provider-prefixed ids such as - "vertex_ai/gemini-2.5-pro". OpenRouter models use extra_body.reasoning instead and - are covered by test_litellm_openrouter_controls.py. + Gemini 2.5 and 3.x expose a thinking budget that LiteLLM maps from + reasoning_effort. The support probe in chat_completion matches bare and + provider-prefixed ids such as "vertex_ai/gemini-2.5-pro" and + "gemini/gemini-3.5-flash". OpenRouter models use extra_body.reasoning instead + and are covered by test_litellm_openrouter_controls.py. """ def _isolate_env(self, monkeypatch): @@ -1024,7 +1025,7 @@ def _isolate_env(self, monkeypatch): @pytest.mark.asyncio async def test_gemini_prefixed_forms_get_reasoning_effort(self, monkeypatch, mock_logger): - """Bare and provider-prefixed Gemini 2.5 ids all receive the configured reasoning_effort.""" + """Bare and provider-prefixed Gemini 2.5/3.x ids all receive the configured reasoning_effort.""" fake_settings = create_mock_settings("low") monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) self._isolate_env(monkeypatch) @@ -1034,6 +1035,11 @@ async def test_gemini_prefixed_forms_get_reasoning_effort(self, monkeypatch, moc "gemini-2.5-flash", "gemini/gemini-2.5-pro", "vertex_ai/gemini-2.5-pro", + "gemini-3.7-flash", + "gemini/gemini-3.5-flash", + "gemini/gemini-3.5-flash-lite", + "vertex_ai/gemini-3.5-flash", + "gemini/gemini-3.8-flash", ] for model in gemini_models: @@ -1050,12 +1056,25 @@ async def test_gemini_prefixed_forms_get_reasoning_effort(self, monkeypatch, moc @pytest.mark.asyncio async def test_non_listed_gemini_gets_no_reasoning_effort(self, monkeypatch, mock_logger): - """A Gemini model not in the support list (e.g. 1.5) must not receive reasoning_effort.""" + """A Gemini id the bundled cost map does not flag as reasoning-capable must not receive reasoning_effort. + + Locks in the deliberate 3.x exclusions: gemini-3.1-flash and gemini-3.5-pro + are absent from litellm's bundled cost map, and gemini-3.1-pro resolves + only under the deepinfra/google/ prefix, so the metadata probe's suffix + lookup never flags their native spellings. Do not re-add them without + checking the bundled registry first. + """ fake_settings = create_mock_settings("low") monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) self._isolate_env(monkeypatch) - for model in ("openrouter/google/gemini-1.5-pro", "gemini-1.5-flash"): + for model in ( + "openrouter/google/gemini-1.5-pro", + "gemini-1.5-flash", + "gemini/gemini-3.1-flash", + "gemini/gemini-3.1-pro", + "gemini-3.5-pro", + ): with patch('pr_agent.algo.ai_handlers.litellm_ai_handler.acompletion', new_callable=AsyncMock) as mock_completion: mock_completion.return_value = create_mock_acompletion_response() From dd91da0fc4549fffbf67329a81fe376b7407e9c4 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:56:33 +0545 Subject: [PATCH 183/238] fix: keep tags in reasoning support probe (#3502) --- .github/workflows/build-and-test.yaml | 2 +- docker/Dockerfile | 15 ++-- docker/Dockerfile.lambda | 4 ++ .../algo/ai_handlers/litellm_ai_handler.py | 21 +++--- .../unittest/test_litellm_reasoning_effort.py | 71 +++++++++++++++++++ 5 files changed, 98 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 3366bdec11..a8be9cda53 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -123,7 +123,7 @@ jobs: name: Test on Python ${{ matrix.python-version }} env: PYTHONPATH: . - # See docker/Dockerfile's test stage: pin litellm's bundled cost map so + # See docker/Dockerfile's base stage: pin litellm's bundled cost map so # a remote change to it cannot redden the suite without a version bump. LITELLM_LOCAL_MODEL_COST_MAP: "True" run: uv run --frozen --python ${{ matrix.python-version }} pytest -v tests/unittest diff --git a/docker/Dockerfile b/docker/Dockerfile index 6c4eb37425..ac830e5f27 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,14 @@ FROM python:3.14.7-slim AS base COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ +# litellm fetches its model cost map over the network at import time, so a +# third-party change to that map can change runtime behavior with no image +# change and no version bump. Use the map bundled with the litellm pinned in +# uv.lock so every stage (test and runtime) behaves how it was built and tested +# rather than tracking today's remote data; new models then arrive with a +# deliberate litellm bump in the lockfile. +ENV LITELLM_LOCAL_MODEL_COST_MAP=True + RUN apt-get update && apt-get install --no-install-recommends -y git curl && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -52,11 +60,8 @@ CMD ["python", "pr_agent/mosaico/server.py"] FROM base AS test -# litellm fetches its model cost map over the network at import time, so a -# third-party change to that map can turn the suite red with no change here -# and no version bump. Use the map bundled with the litellm pinned in uv.lock -# so the suite tests the dependency we ship rather than today's remote data. -ENV LITELLM_LOCAL_MODEL_COST_MAP=True +# The base stage pins LITELLM_LOCAL_MODEL_COST_MAP for every stage; kept here +# only as documentation that the unit suite keys off the bundled cost map. RUN uv sync --frozen --no-install-project COPY pr_agent pr_agent COPY tests tests diff --git a/docker/Dockerfile.lambda b/docker/Dockerfile.lambda index b2100ebf70..9f7705dd21 100644 --- a/docker/Dockerfile.lambda +++ b/docker/Dockerfile.lambda @@ -5,6 +5,10 @@ FROM public.ecr.aws/lambda/python:3.14 AS base COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ +# Use the cost map bundled with the litellm pinned in uv.lock so lambda images +# behave as built rather than fetching today's remote data at import time. +ENV LITELLM_LOCAL_MODEL_COST_MAP=True + RUN dnf update -y && \ dnf install -y gcc python3-devel git && \ dnf clean all diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index a244f429e4..e07a05783c 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -3457,24 +3457,27 @@ def _litellm_supports_reasoning(model: str) -> bool: GROK_REASONING_EFFORT_LEVELS registry, so they do not depend on map versions) and bare o3/o4/Gemini ids resolve directly. To mirror the old ``endswith("/")`` membership, probe every suffix of the id (after - stripping a routing suffix such as ``:nitro`` and the leading ``openrouter/`` - segment, whose provider-prefixed slugs can carry metadata for models this - handler never routed there) plus the ``xai/``-prefixed bare name. Claude - models are excluded by the caller via _is_claude_family_model: litellm maps - their reasoning_effort to a thinking token budget. + stripping the leading ``openrouter/`` segment, whose provider-prefixed slugs + can carry metadata for models this handler never routed there) plus the + ``xai/``-prefixed bare name. Routing suffixes such as ``:nitro`` are stripped + by the OpenRouter caller before this gate runs; for every other provider the + full tagged id is kept, so a local ``ollama/o3:latest`` only resolves when its + exact spelling is registered and does not fall through to the bare ``o3`` + entry. Claude models are excluded by the caller via _is_claude_family_model: + litellm maps their reasoning_effort to a thinking token budget. The bundled cost map is consulted directly rather than via ``litellm.supports_reasoning``: that public helper resolves the model through ``get_llm_provider`` on every call, and provider resolution must stay out of this gate because the api key guard snapshots the resolved provider and its - key. The map is also what #3475 pins through ``LITELLM_LOCAL_MODEL_COST_MAP``, - so the reads are deterministic for the model ids this gate handles. + key. The map is also what #3475 pins through ``LITELLM_LOCAL_MODEL_COST_MAP`` + (now in every Dockerfile stage), so the reads are deterministic for the model + ids this gate handles. """ probe = model if probe.startswith("openrouter/"): probe = probe.removeprefix("openrouter/") - base = probe.rsplit(":", 1)[0] - segments = base.split("/") + segments = probe.split("/") candidates = [] for i in range(len(segments)): candidates.append("/".join(segments[i:])) diff --git a/tests/unittest/test_litellm_reasoning_effort.py b/tests/unittest/test_litellm_reasoning_effort.py index 7e5e6d1e2d..44a21a4fef 100644 --- a/tests/unittest/test_litellm_reasoning_effort.py +++ b/tests/unittest/test_litellm_reasoning_effort.py @@ -1148,6 +1148,77 @@ async def test_metadata_only_model_gets_reasoning_effort(self, monkeypatch, mock assert call_kwargs.get("reasoning_effort") == "low", f"reasoning_effort dropped for {model}" +class TestLiteLLMReasoningEffortTaggedModels: + """Reasoning support is probed on the exact model id, so a ``:tag`` a local + provider attaches (ollama/replicate Bedrock) must not fall through to the + bare metadata entry. The OpenRouter caller strips its own routing suffix + before this gate, so only non-OpenRouter tagged ids are exercised here. + """ + + def _isolate_env(self, monkeypatch): + # LiteLLMAIHandler.__init__ branches on these; clear them for a deterministic handler. + for name in ("AWS_USE_IMDS", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", "AWS_REGION_NAME", "OPENAI_API_KEY"): + monkeypatch.delenv(name, raising=False) + + @pytest.mark.asyncio + async def test_tagged_ollama_id_does_not_fall_through_to_bare_entry(self, monkeypatch, mock_logger): + """ollama/o3:latest must not receive reasoning_effort just because the bare o3 + entry is reasoning-capable; the tagged spelling is not what litellm registers. + """ + fake_settings = create_mock_settings("low") + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="ollama/o3:latest", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert "reasoning_effort" not in kwargs + assert "allowed_openai_params" not in kwargs + + @pytest.mark.asyncio + @pytest.mark.parametrize("model", [ + "ollama/deepseek-r1:8b", + "gpt-oss:20b", + "replicate/deepseek-ai/deepseek-r1:2025-01-12", + ]) + async def test_tagged_non_reasoning_spellings_receive_no_effort(self, monkeypatch, mock_logger, model): + """Tagged ids litellm does not register as reasoning-capable stay off the path.""" + fake_settings = create_mock_settings("low") + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion(model=model, system="system", user="user") + + kwargs = completion.call_args.kwargs + assert "reasoning_effort" not in kwargs, f"unexpected reasoning_effort for {model}" + + @pytest.mark.asyncio + async def test_exact_tagged_spelling_still_enables_reasoning(self, monkeypatch, mock_logger): + """A tagged id registered with its exact spelling still resolves to reasoning_effort.""" + monkeypatch.setitem( + litellm.model_cost, "ollama/qwen3:8b", {"supports_reasoning": True} + ) + fake_settings = create_mock_settings("low") + monkeypatch.setattr(litellm_handler, "get_settings", lambda: fake_settings) + self._isolate_env(monkeypatch) + + with patch.object(litellm_handler, "acompletion", new_callable=AsyncMock) as completion: + completion.return_value = create_mock_acompletion_response() + await LiteLLMAIHandler().chat_completion( + model="ollama/qwen3:8b", system="system", user="user", + ) + + kwargs = completion.call_args.kwargs + assert kwargs["reasoning_effort"] == "low" + + class TestLiteLLMReasoningEffortGrok: """Cover Grok-specific reasoning levels without duplicating generic OpenRouter tests.""" From 26a9791eb5337a99e7d3bcafcec2457d2be67a19 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sun, 20 Sep 2026 05:12:10 +0800 Subject: [PATCH 184/238] fix(tests): validate Gitea E2E file writes and tool results (#3513) --- tests/e2e_tests/test_gitea_app.py | 97 +++++---- tests/unittest/test_gitea_e2e_branch_api.py | 16 +- tests/unittest/test_gitea_e2e_results.py | 230 ++++++++++++++++++++ 3 files changed, 292 insertions(+), 51 deletions(-) create mode 100644 tests/unittest/test_gitea_e2e_results.py diff --git a/tests/e2e_tests/test_gitea_app.py b/tests/e2e_tests/test_gitea_app.py index fc7669d3dc..50eadaed95 100644 --- a/tests/e2e_tests/test_gitea_app.py +++ b/tests/e2e_tests/test_gitea_app.py @@ -16,6 +16,37 @@ setup_logger(log_level) logger = get_logger() +def _missing_gitea_tool_results(repo_api_url, pr_number, headers): + """Check the description and the comments for the default tools' final output.""" + response = requests.get( + f"{repo_api_url}/pulls/{pr_number}", + headers=headers, + timeout=30, + ) + response.raise_for_status() + description_lines = (response.json().get("body") or "").splitlines() + missing = {"/describe", "/review", "/improve"} + if all(header in description_lines for header in ("### **PR Type**", "### **Description**")): + missing.remove("/describe") + + comment_markers = { + "/review": {""}, + "/improve": {"", ""}, + } + response = requests.get( + f"{repo_api_url}/issues/{pr_number}/comments", + headers=headers, + timeout=30, + ) + response.raise_for_status() + for comment in response.json(): + lines = set((comment.get("body") or "").splitlines()) + for command, markers in comment_markers.items(): + if lines.intersection(markers): + missing.discard(command) + return sorted(missing) + + def test_e2e_run_gitea_app(): repo_name = 'pr-agent-tests' owner = 'codiumai' @@ -66,29 +97,24 @@ def test_e2e_run_gitea_app(): import base64 file_content_encoded = base64.b64encode(NEW_FILE_CONTENT.encode()).decode() - try: - response = requests.get( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}?ref={new_branch}", - headers=headers - ) + response = requests.get( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}?ref={new_branch}", + headers=headers + ) + file_data = { + "message": "Update cli_pip.py", + "content": file_content_encoded, + "branch": new_branch + } + if response.status_code == 404: + file_data["message"] = "Add cli_pip.py" + write_file = requests.post + else: response.raise_for_status() - existing_file = response.json() - file_sha = existing_file.get('sha') - - file_data = { - 'message': 'Update cli_pip.py', - 'content': file_content_encoded, - 'sha': file_sha, - 'branch': new_branch - } - except: - file_data = { - 'message': 'Add cli_pip.py', - 'content': file_content_encoded, - 'branch': new_branch - } - - response = requests.put( + file_data["sha"] = response.json()["sha"] + write_file = requests.put + + response = write_file( f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}", headers=headers, json=file_data @@ -111,32 +137,19 @@ def test_e2e_run_gitea_app(): pr = response.json() pr_number = pr['number'] + missing_tools = ["/describe", "/review", "/improve"] for i in range(NUM_MINUTES): logger.info("Waiting for the PR to get all the tool results...") time.sleep(60) - response = requests.get( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/issues/{pr_number}/comments", - headers=headers + missing_tools = _missing_gitea_tool_results( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}", pr_number, headers ) - response.raise_for_status() - comments = response.json() - - if len(comments) >= 5: - valid_review = False - for comment in comments: - if comment['body'].startswith('## PR Reviewer Guide 🔍'): - valid_review = True - break - if valid_review: - break - else: - logger.error("REVIEW feedback is invalid") - raise Exception("REVIEW feedback is invalid") - else: - logger.info(f"Waiting for the PR to get all the tool results. {i + 1} minute(s) passed") + if not missing_tools: + break + logger.info(f"Still waiting for {', '.join(missing_tools)} after {i + 1} minute(s)") else: - raise AssertionError(f"After {NUM_MINUTES} minutes, the PR did not get all the tool results") + raise AssertionError(f"After {NUM_MINUTES} minutes, missing tool results: {', '.join(missing_tools)}") logger.info(f"Cleaning up: closing PR and deleting branch {new_branch}") diff --git a/tests/unittest/test_gitea_e2e_branch_api.py b/tests/unittest/test_gitea_e2e_branch_api.py index 631142f4a3..9ae9b20a05 100644 --- a/tests/unittest/test_gitea_e2e_branch_api.py +++ b/tests/unittest/test_gitea_e2e_branch_api.py @@ -133,16 +133,11 @@ def test_gitea_e2e_does_not_repeat_successful_pr_cleanup(monkeypatch): """Do not close an already-closed PR again when normal branch deletion fails.""" gitea_e2e, http, _ = _setup_gitea_case(monkeypatch) file_response = MagicMock() + file_response.status_code = 200 file_response.json.return_value = {"sha": "file-sha"} - comments_response = MagicMock() - comments_response.json.return_value = [ - {"body": "## PR Reviewer Guide 🔍"}, - {"body": "comment 2"}, - {"body": "comment 3"}, - {"body": "comment 4"}, - {"body": "comment 5"}, - ] - http.get.side_effect = [file_response, comments_response] + results = MagicMock(return_value=[]) + monkeypatch.setattr(gitea_e2e, "_missing_gitea_tool_results", results) + http.get.side_effect = [file_response] pr_response = MagicMock() pr_response.json.return_value = {"number": 123} @@ -160,6 +155,9 @@ def test_gitea_e2e_does_not_repeat_successful_pr_cleanup(monkeypatch): assert caught.value is cleanup_failure new_branch = _assert_native_create_contract(http) + results.assert_called_once_with( + "https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests", 123, _expected_headers() + ) http.patch.assert_called_once_with( "https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests/pulls/123", headers=_expected_headers(), diff --git a/tests/unittest/test_gitea_e2e_results.py b/tests/unittest/test_gitea_e2e_results.py new file mode 100644 index 0000000000..50f2aad57d --- /dev/null +++ b/tests/unittest/test_gitea_e2e_results.py @@ -0,0 +1,230 @@ +import base64 +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from requests import Response +from requests.exceptions import ConnectionError, HTTPError, JSONDecodeError, SSLError, Timeout + +from tests.e2e_tests import test_gitea_app as gitea_e2e + +REPO_API = "https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests" +DESCRIPTION = "### **PR Type**\nbug fix\n\n### **Description**\n- Update the CLI." +REVIEW = {"body": "## Team review\n\n\n\nReview results."} +IMPROVE = {"body": "## Team suggestions\n\n\n\nSuggested changes."} + + +def _response(payload, status=200): + """Use real Requests status and JSON handling without a network connection.""" + response = Response() + response.status_code = status + response.url = REPO_API + response._content = json.dumps(payload).encode("utf-8") + response._content_consumed = True + return response + + +@pytest.fixture +def case(monkeypatch): + """Isolate the E2E settings, HTTP calls, logging, and polling delays.""" + settings = SimpleNamespace( + config=SimpleNamespace(git_provider=None), + get={"GITEA.URL": "https://gitea.example.test", "GITEA.TOKEN": "test-token"}.get, + ) + http = MagicMock(spec=["get", "post", "put", "patch", "delete"]) + sleep = MagicMock() + monkeypatch.setattr(gitea_e2e, "get_settings", lambda: settings) + monkeypatch.setattr(gitea_e2e, "requests", http) + monkeypatch.setattr(gitea_e2e, "logger", MagicMock()) + monkeypatch.setattr(gitea_e2e.time, "sleep", sleep) + monkeypatch.setattr(gitea_e2e, "NUM_MINUTES", 2) + + def get(url, **kwargs): + if "/contents/" in url: + return _response({"sha": "file-sha"}) + if url == f"{REPO_API}/pulls/123": + return _response({"body": DESCRIPTION}) + if url == f"{REPO_API}/issues/123/comments": + return _response([REVIEW, IMPROVE]) + raise AssertionError(f"Unexpected GET: {url}") + + def post(url, **kwargs): + if url.endswith("/branches"): + return _response({}, 201) + if "/contents/" in url: + return _response({}, 201) + if url.endswith("/pulls"): + return _response({"number": 123}, 201) + raise AssertionError(f"Unexpected POST: {url}") + + http.get.side_effect = get + http.post.side_effect = post + http.put.return_value = _response({}) + http.patch.return_value = _response({}) + http.delete.return_value = _response(None, 204) + return http, sleep + + +@pytest.mark.parametrize("file_status", [200, 404]) +def test_e2e_updates_existing_files_and_creates_only_missing_files(case, file_status): + http, sleep = case + original_get = http.get.side_effect + http.get.side_effect = lambda url, **kwargs: ( + _response({"sha": "file-sha"}, file_status) if "/contents/" in url else original_get(url, **kwargs) + ) + + gitea_e2e.test_e2e_run_gitea_app() + + writes = [call for call in http.mock_calls if call[0] in {"post", "put"} and "/contents/" in call.args[0]] + assert len(writes) == 1 + write = writes[0] + assert write[0] == ("post" if file_status == 404 else "put") + assert write.args[0] == f"{REPO_API}/contents/{gitea_e2e.FILE_PATH}" + payload = write.kwargs["json"] + assert payload["branch"] == http.post.call_args_list[0].kwargs["json"]["new_branch_name"] + assert base64.b64decode(payload["content"]).decode() == gitea_e2e.NEW_FILE_CONTENT + assert payload["message"] == ("Add cli_pip.py" if file_status == 404 else "Update cli_pip.py") + assert ("sha" in payload) == (file_status == 200) + if file_status == 200: + assert payload["sha"] == "file-sha" + assert write.kwargs["headers"]["Authorization"] == "token test-token" + sleep.assert_called_once_with(60) + http.patch.assert_called_once() + http.delete.assert_called_once() + + +@pytest.mark.parametrize("status", [401, 403, 429, 500, 503]) +def test_file_lookup_http_errors_do_not_trigger_a_write(case, status): + http, _ = case + response = _response({}, status) + http.get.return_value = response + http.get.side_effect = None + + with pytest.raises(HTTPError) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value.response is response + http.put.assert_not_called() + assert http.post.call_count == 1 + http.delete.assert_called_once() + + +@pytest.mark.parametrize("error", [Timeout("timeout"), ConnectionError("connection"), SSLError("certificate")]) +def test_file_lookup_transport_errors_preserve_the_original_exception(case, error): + http, _ = case + http.get.side_effect = error + + with pytest.raises(type(error)) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value is error + http.put.assert_not_called() + assert http.post.call_count == 1 + http.delete.assert_called_once() + + +@pytest.mark.parametrize("malformed_json", [True, False]) +def test_invalid_file_metadata_is_not_treated_as_a_missing_file(case, malformed_json): + http, _ = case + response = _response({}) + if malformed_json: + response._content = b"{" + http.get.side_effect = None + http.get.return_value = response + + with pytest.raises(JSONDecodeError if malformed_json else KeyError): + gitea_e2e.test_e2e_run_gitea_app() + + http.put.assert_not_called() + assert http.post.call_count == 1 + http.delete.assert_called_once() + + +@pytest.mark.parametrize("missing", ["/describe", "/review", "/improve"]) +def test_missing_tool_output_times_out_with_its_name_and_cleans_up(case, missing): + http, sleep = case + original_get = http.get.side_effect + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123" and missing == "/describe": + return _response({"body": "update cli_pip.py"}) + if url == f"{REPO_API}/issues/123/comments": + comments = [REVIEW, IMPROVE] + if missing == "/review": + comments = [IMPROVE] + elif missing == "/improve": + comments = [REVIEW] + return _response(comments) + return original_get(url, **kwargs) + + http.get.side_effect = get + + with pytest.raises(AssertionError, match=missing): + gitea_e2e.test_e2e_run_gitea_app() + + assert sleep.call_count == 2 + http.patch.assert_called_once() + http.delete.assert_called_once() + + +def test_polling_waits_for_results_instead_of_failing_on_comment_count(case): + http, sleep = case + original_get = http.get.side_effect + poll = {"number": 0} + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123": + poll["number"] += 1 + body = "update cli_pip.py" if poll["number"] == 1 else DESCRIPTION + return _response({"body": body}) + if url == f"{REPO_API}/issues/123/comments": + if poll["number"] == 1: + comments = [{"body": f"unrelated {i}"} for i in range(8)] + return _response(comments) + return original_get(url, **kwargs) + + http.get.side_effect = get + + gitea_e2e.test_e2e_run_gitea_app() + + assert sleep.call_count == 2 + http.patch.assert_called_once() + http.delete.assert_called_once() + + +def test_result_detection_accepts_no_suggestions_and_reads_comments_once(case): + http, _ = case + no_suggestions = {"body": "## No suggestions\n\n"} + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123": + return _response({"body": DESCRIPTION}) + if url == f"{REPO_API}/issues/123/comments": + return _response([REVIEW, no_suggestions]) + raise AssertionError(f"Unexpected GET: {url}") + + http.get.side_effect = get + + assert gitea_e2e._missing_gitea_tool_results(REPO_API, 123, {"Authorization": "token test"}) == [] + pr_call = next(call for call in http.get.call_args_list if call.args[0] == f"{REPO_API}/pulls/123") + comment_calls = [call for call in http.get.call_args_list if "/comments" in call.args[0]] + assert pr_call.kwargs["timeout"] == 30 + assert len(comment_calls) == 1 + assert comment_calls[0].kwargs["timeout"] == 30 + + +def test_result_detection_reports_missing_tools_without_comment_order_assumptions(case): + http, _ = case + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123": + return _response({"body": "update cli_pip.py"}) + if url == f"{REPO_API}/issues/123/comments": + comments = [IMPROVE, {"body": "unrelated"}, REVIEW] + return _response(comments) + raise AssertionError(f"Unexpected GET: {url}") + + http.get.side_effect = get + + assert gitea_e2e._missing_gitea_tool_results(REPO_API, 123, {}) == ["/describe"] From 4515b5718c4592860ff57398e0cf1493587a9814 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sun, 20 Sep 2026 05:12:35 +0800 Subject: [PATCH 185/238] fix: redact sensitive AWS Secrets Manager error logs (#3512) --- .../aws_secrets_manager_provider.py | 26 +++- .../test_aws_secrets_manager_logging.py | 138 ++++++++++++++++++ 2 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 tests/unittest/test_aws_secrets_manager_logging.py diff --git a/pr_agent/secret_providers/aws_secrets_manager_provider.py b/pr_agent/secret_providers/aws_secrets_manager_provider.py index fec8026fc5..d3cd15fa2b 100644 --- a/pr_agent/secret_providers/aws_secrets_manager_provider.py +++ b/pr_agent/secret_providers/aws_secrets_manager_provider.py @@ -7,6 +7,18 @@ from pr_agent.secret_providers.secret_provider import SecretProvider +def _error_kind(error: Exception) -> str: + # Keep the AWS error code for diagnostics; never log the associated message. + response = getattr(error, "response", None) + if isinstance(response, dict): + error_details = response.get("Error") + if isinstance(error_details, dict): + code = error_details.get("Code") + if isinstance(code, str) and code: + return code + return type(error).__name__ + + class AWSSecretsManagerProvider(SecretProvider): def __init__(self): try: @@ -21,7 +33,8 @@ def __init__(self): if not self.secret_arn: raise ValueError("AWS Secrets Manager ARN is not configured") except Exception as e: - get_logger().error(f"Failed to initialize AWS Secrets Manager Provider: {e}") + # Avoid logging SDK error text, which can include credential-process output. + get_logger().error(f"Failed to initialize AWS Secrets Manager Provider: {_error_kind(e)}") raise e def get_secret(self, secret_name: str) -> str: @@ -32,7 +45,8 @@ def get_secret(self, secret_name: str) -> str: response = self.client.get_secret_value(SecretId=secret_name) return response['SecretString'] except Exception as e: - get_logger().warning(f"Failed to get secret {secret_name} from AWS Secrets Manager: {e}") + # Omit the secret name because GitLab passes its webhook token here. + get_logger().warning(f"Failed to get secret from AWS Secrets Manager: {_error_kind(e)}") return "" def get_all_secrets(self) -> dict: @@ -43,7 +57,9 @@ def get_all_secrets(self) -> dict: response = self.client.get_secret_value(SecretId=self.secret_arn) return json.loads(response['SecretString']) except Exception as e: - get_logger().error(f"Failed to get secrets from AWS Secrets Manager {self.secret_arn}: {e}") + get_logger().error( + f"Failed to get secrets from AWS Secrets Manager {self.secret_arn}: {_error_kind(e)}" + ) return {} def store_secret(self, secret_name: str, secret_value: str): @@ -53,5 +69,7 @@ def store_secret(self, secret_name: str, secret_value: str): SecretString=secret_value ) except Exception as e: - get_logger().error(f"Failed to store secret {secret_name} in AWS Secrets Manager: {e}") + get_logger().error( + f"Failed to store secret {secret_name} in AWS Secrets Manager: {_error_kind(e)}" + ) raise e diff --git a/tests/unittest/test_aws_secrets_manager_logging.py b/tests/unittest/test_aws_secrets_manager_logging.py new file mode 100644 index 0000000000..44e70aa9ce --- /dev/null +++ b/tests/unittest/test_aws_secrets_manager_logging.py @@ -0,0 +1,138 @@ +"""Keep secret lookup tokens and SDK error details out of provider logs.""" + +from unittest.mock import MagicMock + +import boto3 +import pytest +from botocore.exceptions import ClientError, CredentialRetrievalError +from botocore.stub import Stubber + +import pr_agent.secret_providers.aws_secrets_manager_provider as aws_provider +from pr_agent.log import get_logger + +ERROR_SECRET = "aws-error-secret-sentinel" +WEBHOOK_TOKEN = "webhook-token-secret-sentinel" +SECRET_NAME = "test-secret" +SECRET_ARN = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret" + + +@pytest.fixture +def provider_settings(monkeypatch): + settings = { + "aws_secrets_manager.region_name": "us-east-1", + "aws_secrets_manager.secret_arn": SECRET_ARN, + } + monkeypatch.setattr(aws_provider, "get_settings", lambda: settings) + + +@pytest.fixture(params=[False, True], ids=["console", "json"]) +def provider_logs(request): + messages = [] + sink = get_logger().add( + messages.append, + filter=lambda record: record["name"] == aws_provider.__name__, + level="DEBUG", + format="{message}", + serialize=request.param, + backtrace=True, + diagnose=True, + ) + try: + yield messages + finally: + get_logger().remove(sink) + + +def assert_safe_log(messages, expected_message, level): + assert len(messages) == 1 + message = messages[0] + assert ERROR_SECRET not in str(message) + assert WEBHOOK_TOKEN not in str(message) + assert "Traceback (most recent call last)" not in str(message) + assert message.record["message"] == expected_message + assert message.record["level"].name == level + assert message.record["exception"] is None + assert not message.record["extra"] + + +@pytest.mark.parametrize("method", ["__init__", "get_secret", "get_all_secrets", "store_secret"]) +def test_sdk_error_details_are_not_logged(monkeypatch, provider_settings, provider_logs, method): + # Model the SDK error that includes stderr from a failed credential_process. + error = CredentialRetrievalError(provider="custom-process", error_msg=ERROR_SECRET) + client = MagicMock() + client_factory = MagicMock(return_value=client) + monkeypatch.setattr(aws_provider.boto3, "client", client_factory) + + if method == "__init__": + client_factory.side_effect = error + with pytest.raises(CredentialRetrievalError) as caught: + aws_provider.AWSSecretsManagerProvider() + assert caught.value is error + expected = "Failed to initialize AWS Secrets Manager Provider: CredentialRetrievalError" + else: + provider = aws_provider.AWSSecretsManagerProvider() + if method == "store_secret": + client.put_secret_value.side_effect = error + with pytest.raises(CredentialRetrievalError) as caught: + provider.store_secret(SECRET_NAME, "test-value") + assert caught.value is error + expected = f"Failed to store secret {SECRET_NAME} in AWS Secrets Manager: CredentialRetrievalError" + else: + client.get_secret_value.side_effect = error + if method == "get_secret": + assert provider.get_secret(SECRET_NAME) == "" + expected = "Failed to get secret from AWS Secrets Manager: CredentialRetrievalError" + else: + assert provider.get_all_secrets() == {} + expected = f"Failed to get secrets from AWS Secrets Manager {SECRET_ARN}: CredentialRetrievalError" + + assert ERROR_SECRET in str(error) # Do not mutate the exception returned to callers. + assert_safe_log(provider_logs, expected, "WARNING" if method == "get_secret" else "ERROR") + + +def test_unmodeled_aws_error_code_is_logged_without_message( + monkeypatch, provider_settings, provider_logs +): + error = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": ERROR_SECRET}}, + "GetSecretValue", + ) + client = MagicMock() + monkeypatch.setattr(aws_provider.boto3, "client", MagicMock(return_value=client)) + provider = aws_provider.AWSSecretsManagerProvider() + client.get_secret_value.side_effect = error + + assert provider.get_secret(SECRET_NAME) == "" + assert ERROR_SECRET in str(error) + assert_safe_log( + provider_logs, + "Failed to get secret from AWS Secrets Manager: AccessDeniedException", + "WARNING", + ) + + +def test_webhook_token_is_not_logged_on_service_error(monkeypatch, provider_settings, provider_logs): + client = boto3.session.Session().client( + "secretsmanager", + region_name="us-east-1", + aws_access_key_id="testing", + aws_secret_access_key="testing", + ) + try: + monkeypatch.setattr(aws_provider.boto3, "client", lambda *args, **kwargs: client) + provider = aws_provider.AWSSecretsManagerProvider() + with Stubber(client) as stubber: + # Keep the service error free of secrets to isolate the token-bearing SecretId. + stubber.add_client_error( + "get_secret_value", + service_error_code="InternalServiceError", + service_message="Temporary service failure", + http_status_code=500, + expected_params={"SecretId": WEBHOOK_TOKEN}, + ) + assert provider.get_secret(WEBHOOK_TOKEN) == "" + stubber.assert_no_pending_responses() + finally: + client.close() + + assert_safe_log(provider_logs, "Failed to get secret from AWS Secrets Manager: InternalServiceError", "WARNING") From 9f0469b7cc153a6864aa29e086794457237dfcb7 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sun, 20 Sep 2026 05:13:01 +0800 Subject: [PATCH 186/238] chore(ci): scope Release Drafter permissions by event (#3520) --- .github/workflows/release-drafter.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 934f73d008..42715be406 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -15,16 +15,23 @@ permissions: jobs: update_release_draft: + if: github.event_name == 'push' permissions: contents: write - pull-requests: write + pull-requests: read runs-on: ubuntu-latest steps: - - if: github.event_name == 'push' - uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 + - uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 with: token: ${{ secrets.GITHUB_TOKEN }} - - if: github.event_name == 'pull_request_target' - uses: release-drafter/release-drafter/autolabeler@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 + + auto_label: + if: github.event_name == 'pull_request_target' + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter/autolabeler@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 with: token: ${{ secrets.GITHUB_TOKEN }} From 4f4b6f4c4e2a3ac0bc4fe43b50c2b24665594e61 Mon Sep 17 00:00:00 2001 From: Azhar Khan Date: Sun, 20 Sep 2026 02:43:28 +0530 Subject: [PATCH 187/238] refactor(algo): extract comment identity to dedicated module (1/3) (#3511) (#3519) --- pr_agent/agent/pr_agent.py | 3 +- pr_agent/algo/comment_identity.py | 209 +++++++++++++++++ pr_agent/algo/review_merge.py | 3 +- pr_agent/algo/utils.py | 213 +----------------- .../git_providers/azuredevops_provider.py | 16 +- pr_agent/git_providers/codecommit_provider.py | 4 +- pr_agent/git_providers/git_provider.py | 9 +- pr_agent/git_providers/github_provider.py | 6 +- pr_agent/git_providers/gitlab_provider.py | 15 +- pr_agent/git_providers/local_git_provider.py | 3 +- pr_agent/git_providers/plain_diff_provider.py | 3 +- pr_agent/tools/pr_code_suggestions.py | 14 +- pr_agent/tools/pr_description.py | 2 +- pr_agent/tools/pr_questions.py | 3 +- pr_agent/tools/pr_reviewer.py | 14 +- .../unittest/test_azure_devops_incremental.py | 2 +- tests/unittest/test_azure_devops_provider.py | 4 +- tests/unittest/test_bitbucket_provider.py | 4 +- .../test_code_suggestions_comment_identity.py | 2 +- tests/unittest/test_codecommit_provider.py | 2 +- tests/unittest/test_convert_to_markdown.py | 3 +- tests/unittest/test_gitlab_provider.py | 2 +- tests/unittest/test_hidden_comment_markers.py | 2 +- .../test_markdown_ticket_output_core.py | 2 +- .../unittest/test_pr_code_suggestions_core.py | 3 +- .../test_pr_description_output_core.py | 3 +- tests/unittest/test_pr_questions_helpers.py | 2 +- tests/unittest/test_pr_reviewer_core.py | 3 +- .../test_pr_reviewer_finding_state.py | 14 +- .../unittest/test_review_comment_identity.py | 4 +- ...st_review_persistent_comment_authorship.py | 2 +- 31 files changed, 301 insertions(+), 270 deletions(-) create mode 100644 pr_agent/algo/comment_identity.py diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index 7a3255b353..40d30b1e21 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -11,7 +11,8 @@ from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.cli_args import CliArgs -from pr_agent.algo.utils import add_comment_identity, comment_matches_identity, update_settings_from_args +from pr_agent.algo.comment_identity import add_comment_identity, comment_matches_identity +from pr_agent.algo.utils import update_settings_from_args from pr_agent.config_loader import get_settings, global_settings from pr_agent.git_providers import get_git_provider_with_context from pr_agent.git_providers.github_provider import IncompletePullRequestFilesError diff --git a/pr_agent/algo/comment_identity.py b/pr_agent/algo/comment_identity.py new file mode 100644 index 0000000000..edaac5c086 --- /dev/null +++ b/pr_agent/algo/comment_identity.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import string +from enum import Enum +from typing import Iterable + +from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger + + +class PRReviewHeader(str, Enum): + REGULAR = "## PR Reviewer Guide" + INCREMENTAL = "## Incremental PR Reviewer Guide" + + +class PRReviewIdentity(str, Enum): + REGULAR = "" + INCREMENTAL = "" + + +class PRCodeSuggestionsHeader(str, Enum): + SUMMARY = "## PR Code Suggestions ✨" + + +class PRCodeSuggestionsIdentity(str, Enum): + SUMMARY = "" + NO_SUGGESTIONS = "" + UNANCHORED = "" + + +class PRDescriptionHeader(str, Enum): + DIAGRAM_WALKTHROUGH = "Diagram Walkthrough" + FILE_WALKTHROUGH = "File Walkthrough" + + +_ALL_COMMENT_IDENTITIES = ( + PRReviewIdentity.REGULAR.value, + PRReviewIdentity.INCREMENTAL.value, + PRCodeSuggestionsIdentity.SUMMARY.value, + PRCodeSuggestionsIdentity.NO_SUGGESTIONS.value, + PRCodeSuggestionsIdentity.UNANCHORED.value, +) +_REVIEW_IDENTITY_HEADER_LINES = 5 +_MARKDOWN_PUNCTUATION_ESCAPE_TABLE = str.maketrans( + {character: f"\\{character}" for character in string.punctuation} +) + + +def _get_configured_heading(setting_name: str, default_heading: str) -> str: + configured_heading = get_settings().get(setting_name) + if ( + not isinstance(configured_heading, str) + or not configured_heading.strip() + or configured_heading.splitlines() != [configured_heading] + ): + get_logger().warning( + f"Invalid {setting_name}; using the default heading" + ) + configured_heading = default_heading + return configured_heading.strip() + + +def format_pr_review_header(incremental: bool = False) -> str: + """Return the visible review heading while keeping identity out of presentation.""" + default_heading = PRReviewHeader.REGULAR.value.removeprefix("## ") + heading = _get_configured_heading("pr_reviewer.review_heading", default_heading) + incremental_prefix = "Incremental " if incremental else "" + return f"## {incremental_prefix}{heading} 🔍" + + +def format_pr_code_suggestions_header(markdown_level: int = 2) -> str: + """Return the visible suggestions heading while keeping identity out of presentation.""" + default_heading = ( + PRCodeSuggestionsHeader.SUMMARY.value + .removeprefix("## ") + .removesuffix(" ✨") + ) + heading = _get_configured_heading( + "pr_code_suggestions.suggestions_heading", + default_heading, + ) + markdown_prefix = "#" * markdown_level + return f"{markdown_prefix} {heading} ✨" + + +def format_pr_questions_header(*, escape_markdown: bool = True) -> str: + """Return the visible heading for top-level /ask answers.""" + heading = _get_configured_heading("pr_questions.ask_heading", "Ask") + if escape_markdown: + heading = heading.translate(_MARKDOWN_PUNCTUATION_ESCAPE_TABLE) + return f"### **{heading}** ❓" + + +def hidden_marker_forms(identity: str) -> tuple[str, ...]: + """Return both stored forms of a known comment identity.""" + for marker in _ALL_COMMENT_IDENTITIES: + reference = f"[{marker[5:-4]}]: https://github.com/The-PR-Agent/pr-agent" + if identity in (marker, reference): + return marker, reference + return (identity,) + + +def render_hidden_marker(identity: str, git_provider=None) -> str: + """Use a link reference on providers that escape HTML comments.""" + forms = hidden_marker_forms(identity) + supports_html = getattr(git_provider, "supports_html_comment_markers", lambda: True) + return forms[-1] if supports_html() is False else forms[0] + + +def comment_matches_identity(body: str, identity: str) -> bool: + """Match hidden markers only as exact lines near the top; legacy headers as prefixes.""" + if not isinstance(body, str) or not isinstance(identity, str) or not identity: + return False + forms = hidden_marker_forms(identity) + if identity.startswith("" - INCREMENTAL = "" - - -class PRCodeSuggestionsHeader(str, Enum): - SUMMARY = "## PR Code Suggestions ✨" - - -class PRCodeSuggestionsIdentity(str, Enum): - SUMMARY = "" - NO_SUGGESTIONS = "" - UNANCHORED = "" - - -_ALL_COMMENT_IDENTITIES = ( - PRReviewIdentity.REGULAR.value, - PRReviewIdentity.INCREMENTAL.value, - PRCodeSuggestionsIdentity.SUMMARY.value, - PRCodeSuggestionsIdentity.NO_SUGGESTIONS.value, - PRCodeSuggestionsIdentity.UNANCHORED.value, -) -_REVIEW_IDENTITY_HEADER_LINES = 5 -_MARKDOWN_PUNCTUATION_ESCAPE_TABLE = str.maketrans( - {character: f"\\{character}" for character in string.punctuation} -) - - -def _get_configured_heading(setting_name: str, default_heading: str) -> str: - configured_heading = get_settings().get(setting_name) - if ( - not isinstance(configured_heading, str) - or not configured_heading.strip() - or configured_heading.splitlines() != [configured_heading] - ): - get_logger().warning( - f"Invalid {setting_name}; using the default heading" - ) - configured_heading = default_heading - return configured_heading.strip() - - -def format_pr_review_header(incremental: bool = False) -> str: - """Return the visible review heading while keeping identity out of presentation.""" - default_heading = PRReviewHeader.REGULAR.value.removeprefix("## ") - heading = _get_configured_heading("pr_reviewer.review_heading", default_heading) - incremental_prefix = "Incremental " if incremental else "" - return f"## {incremental_prefix}{heading} 🔍" - - -def format_pr_code_suggestions_header(markdown_level: int = 2) -> str: - """Return the visible suggestions heading while keeping identity out of presentation.""" - default_heading = ( - PRCodeSuggestionsHeader.SUMMARY.value - .removeprefix("## ") - .removesuffix(" ✨") - ) - heading = _get_configured_heading( - "pr_code_suggestions.suggestions_heading", - default_heading, - ) - markdown_prefix = "#" * markdown_level - return f"{markdown_prefix} {heading} ✨" - - -def format_pr_questions_header(*, escape_markdown: bool = True) -> str: - """Return the visible heading for top-level /ask answers.""" - heading = _get_configured_heading("pr_questions.ask_heading", "Ask") - if escape_markdown: - heading = heading.translate(_MARKDOWN_PUNCTUATION_ESCAPE_TABLE) - return f"### **{heading}** ❓" - - -def hidden_marker_forms(identity: str) -> tuple[str, ...]: - """Return both stored forms of a known comment identity.""" - for marker in _ALL_COMMENT_IDENTITIES: - reference = f"[{marker[5:-4]}]: https://github.com/The-PR-Agent/pr-agent" - if identity in (marker, reference): - return marker, reference - return (identity,) - - -def render_hidden_marker(identity: str, git_provider=None) -> str: - """Use a link reference on providers that escape HTML comments.""" - forms = hidden_marker_forms(identity) - supports_html = getattr(git_provider, "supports_html_comment_markers", lambda: True) - return forms[-1] if supports_html() is False else forms[0] - - -def comment_matches_identity(body: str, identity: str) -> bool: - """Match hidden markers only as exact lines near the top; legacy headers as prefixes.""" - if not isinstance(body, str) or not isinstance(identity, str) or not identity: - return False - forms = hidden_marker_forms(identity) - if identity.startswith("\n\n\n
    ⏱️ Estimated effort to review:' +PR_HEADER_START_WITH = ( + "### **User description**\nupdate cli_pip.py\n\n\n___\n\n### **PR Type**" +) +REVIEW_START_WITH = ( + "## PR Reviewer Guide 🔍\n\n\n\n" + "\n" + ) expected_output = textwrap.dedent(f""" {PRReviewHeader.REGULAR.value} 🔍 Here are some key observations to aid the review process:
    ⏱️ Estimated effort to review:" +) IMPROVE_START_WITH_REGEX_PATTERN = ( r"^## PR Code Suggestions ✨\n\n" r"\n\n" @@ -21,7 +26,8 @@ def main(): user_token = "..." # GitHub user token openai_key = "ghs_afsdfasdfsdf" # Example OpenAI key pr_url = "..." # PR URL, for example 'https://github.com/Codium-ai/pr-agent/pull/809' - command = "/improve" # Command to run (e.g. '/review', '/describe', 'improve', '/ask="What is the purpose of this PR?"') + command = "/improve" # Command to run (e.g. '/review', '/describe', 'improve', + # '/ask="What is the purpose of this PR?"') # Setting the configurations get_settings().set("CONFIG.git_provider", provider) diff --git a/tests/health_test/main.py b/tests/health_test/main.py index ed753bf576..de70a7efaa 100644 --- a/tests/health_test/main.py +++ b/tests/health_test/main.py @@ -29,7 +29,7 @@ async def run_async() -> None: assert isinstance(pr_header_body, str), f"Expected artifact to be str, got {type(pr_header_body).__name__}" assert pr_header_body.startswith("###") and "PR Type" in pr_header_body and "Description" in pr_header_body, \ "PR description artifact missing expected sections" - context['settings'] = copy.deepcopy(original_settings) # Restore settings state after each test to prevent test interference + context['settings'] = copy.deepcopy(original_settings) # Restore settings after each test get_logger().info("PR description generated successfully\n") # Run the 'review' command @@ -40,7 +40,7 @@ async def run_async() -> None: assert isinstance(pr_review_body, str), f"Expected artifact to be str, got {type(pr_review_body).__name__}" assert pr_review_body.startswith("##") and "PR Reviewer Guide" in pr_review_body, \ "PR review artifact missing expected header" - context['settings'] = copy.deepcopy(original_settings) # Restore settings state after each test to prevent test interference + context['settings'] = copy.deepcopy(original_settings) # Restore settings after each test get_logger().info("PR review generated successfully\n") # Run the 'improve' command @@ -51,7 +51,7 @@ async def run_async() -> None: assert isinstance(pr_improve_body, str), f"Expected artifact to be str, got {type(pr_improve_body).__name__}" assert pr_improve_body.startswith("##") and "PR Code Suggestions" in pr_improve_body, \ "PR improve artifact missing expected header" - context['settings'] = copy.deepcopy(original_settings) # Restore settings state after each test to prevent test interference + context['settings'] = copy.deepcopy(original_settings) # Restore settings after each test get_logger().info("PR improvements generated successfully\n") get_logger().info("\n\n========\nHealth test passed successfully\n========") diff --git a/tests/unittest/test_azure_devops_provider.py b/tests/unittest/test_azure_devops_provider.py index 2a8a8fd3e8..3899550692 100644 --- a/tests/unittest/test_azure_devops_provider.py +++ b/tests/unittest/test_azure_devops_provider.py @@ -2417,7 +2417,9 @@ def test_rendered_thread_still_feeds_the_next_run(self): ) provider.azure_devops_client.get_threads.return_value = [stored] provider.pr = SimpleNamespace(last_merge_commit=SimpleNamespace(commit_id="head")) - provider.azure_devops_client.get_item.return_value = SimpleNamespace(content="before\nvalues = set()\nafter") + provider.azure_devops_client.get_item.return_value = SimpleNamespace( + content="before\nvalues = set()\nafter" + ) assert "Not doing this." in provider.get_code_suggestion_thread_context() assert provider.reconcile_code_suggestion_threads() == 1 diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index f9c1560690..bbac645773 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -189,7 +189,16 @@ def test_parse_pr_url(self): assert pr_number == 321 @pytest.mark.parametrize( - ("status", "lines_added", "lines_removed", "filename", "old_filename", "raw_diff", "expected_patch", "edit_type"), + ( + "status", + "lines_added", + "lines_removed", + "filename", + "old_filename", + "raw_diff", + "expected_patch", + "edit_type", + ), [ ( "modified", @@ -1758,7 +1767,10 @@ def test_get_diff_files_multi_merge_diverge_60(self): FilePatchInfo( 'file\nwith\nmultiple\nlines\nto\nemulate\na\nreal\nfile', 'readme\nwithout\nsome\nlines\nto\nsimulate\na\nreal\nfile', - '@@ -1,9 +1,9 @@\n-file\n-with\n-multiple\n+readme\n+without\n+some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n', + ( + "@@ -1,9 +1,9 @@\n-file\n-with\n-multiple\n+readme\n+without\n" + "+some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n" + ), 'Readme.md', edit_type=EDIT_TYPE.MODIFIED, ) @@ -1780,7 +1792,10 @@ def test_get_diff_files_multi_merge_diverge_70(self): FilePatchInfo( 'file\nwith\nsome\nlines\nto\nemulate\na\nreal\nfile', 'readme\nwithout\nsome\nlines\nto\nsimulate\na\nreal\nfile', - '@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n', + ( + "@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n" + " to\n-emulate\n+simulate\n a\n real\n file\n" + ), 'Readme.md', edit_type=EDIT_TYPE.MODIFIED, ) @@ -1802,7 +1817,10 @@ def test_get_diff_files_multi_merge_diverge_816(self): FilePatchInfo( 'file\nwith\nsome\nlines\nto\nemulate\na\nreal\nfile', 'readme\nwithout\nsome\nlines\nto\nsimulate\na\nreal\nfile', - '@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n', + ( + "@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n" + " to\n-emulate\n+simulate\n a\n real\n file\n" + ), 'Readme.md', edit_type=EDIT_TYPE.MODIFIED, ) diff --git a/tests/unittest/test_cli_config_branch.py b/tests/unittest/test_cli_config_branch.py index e04a511433..52e02d658f 100644 --- a/tests/unittest/test_cli_config_branch.py +++ b/tests/unittest/test_cli_config_branch.py @@ -5,7 +5,9 @@ def test_set_parser_supports_config_branch_flag(): - args = cli.set_parser().parse_args(["--pr_url=https://github.com/a/b/pull/1", "--config-branch", "feature", "review"]) + args = cli.set_parser().parse_args( + ["--pr_url=https://github.com/a/b/pull/1", "--config-branch", "feature", "review"] + ) assert args.config_branch == "feature" diff --git a/tests/unittest/test_codecommit_provider.py b/tests/unittest/test_codecommit_provider.py index fc84638c71..8fd27ba912 100644 --- a/tests/unittest/test_codecommit_provider.py +++ b/tests/unittest/test_codecommit_provider.py @@ -1018,7 +1018,10 @@ def update_comment(comment_id, body): assert provider.codecommit_client.update_comment.call_count == 2 provider.codecommit_client.publish_comment.assert_called_once() assert provider.codecommit_client.publish_comment.call_args.kwargs["repo_name"] == "source-repository" - assert provider.codecommit_client.publish_comment.call_args.kwargs["destination_commit"] == "destination-commit-2" + assert ( + provider.codecommit_client.publish_comment.call_args.kwargs["destination_commit"] + == "destination-commit-2" + ) assert provider.codecommit_client.publish_comment.call_args.kwargs["source_commit"] == "source-commit-2" def test_unmarked_persistent_comment_remains_create_only(self): @@ -1163,7 +1166,7 @@ def test_is_valid_codecommit_hostname(self): assert not CodeCommitProvider._is_valid_codecommit_hostname("no-such-region.console.aws.amazon.com") assert not CodeCommitProvider._is_valid_codecommit_hostname("console.aws.amazon.com") - # Test that an error is raised when an invalid CodeCommit URL is provided to the set_pr() method of the CodeCommitProvider class. + # Error is raised when set_pr() receives an invalid CodeCommit URL. # Generated by CodiumAI def test_invalid_codecommit_url(self): provider = CodeCommitProvider() @@ -1257,8 +1260,20 @@ def test_add_additional_newlines(self): expect = "abc\n\ndef\n\n___\n\nghi\n\njkl\n\nmno\n\npqr\n\n" assert CodeCommitProvider._add_additional_newlines(input) == expect # a test example from a real PR - input = "## PR Type:\nEnhancement\n\n___\n## PR Description:\nThis PR introduces a new feature to the script, allowing users to filter servers by name.\n\n___\n## PR Main Files Walkthrough:\n`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n`bar`: The bar script has been updated to list stopped servers.\n" - expect = "## PR Type:\n\nEnhancement\n\n___\n\n## PR Description:\n\nThis PR introduces a new feature to the script, allowing users to filter servers by name.\n\n___\n\n## PR Main Files Walkthrough:\n\n`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n\n`bar`: The bar script has been updated to list stopped servers.\n\n" + input = ( + "## PR Type:\nEnhancement\n\n___\n## PR Description:\n" + "This PR introduces a new feature to the script, allowing users to filter servers by name.\n\n" + "___\n## PR Main Files Walkthrough:\n" + "`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n" + "`bar`: The bar script has been updated to list stopped servers.\n" + ) + expect = ( + "## PR Type:\n\nEnhancement\n\n___\n\n## PR Description:\n\n" + "This PR introduces a new feature to the script, allowing users to filter servers by name.\n\n" + "___\n\n## PR Main Files Walkthrough:\n\n" + "`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n\n" + "`bar`: The bar script has been updated to list stopped servers.\n\n" + ) assert CodeCommitProvider._add_additional_newlines(input) == expect def test_remove_markdown_html(self): diff --git a/tests/unittest/test_convert_to_markdown.py b/tests/unittest/test_convert_to_markdown.py index 9bfcdd1c78..ef4b9ab641 100644 --- a/tests/unittest/test_convert_to_markdown.py +++ b/tests/unittest/test_convert_to_markdown.py @@ -49,9 +49,12 @@ class TestConvertToMarkdown: # Tests that the function works correctly with a simple dictionary input def test_simple_dictionary_input(self): - input_data = {'review': { - 'estimated_effort_to_review_[1-5]': '1, because the changes are minimal and straightforward, focusing on a single functionality addition.\n', - 'relevant_tests': 'No\n', 'possible_issues': 'No\n', 'security_concerns': 'No\n'}} + input_data = {"review": { + "estimated_effort_to_review_[1-5]": ( + "1, because the changes are minimal and straightforward, " + "focusing on a single functionality addition.\n" + ), + "relevant_tests": "No\n", "possible_issues": "No\n", "security_concerns": "No\n"}} expected_output = textwrap.dedent(f"""\ {PRReviewHeader.REGULAR.value} 🔍 @@ -70,9 +73,12 @@ def test_simple_dictionary_input(self): assert convert_to_markdown_v2(input_data).strip() == expected_output.strip() def test_simple_dictionary_input_without_gfm_supported(self): - input_data = {'review': { - 'estimated_effort_to_review_[1-5]': '1, because the changes are minimal and straightforward, focusing on a single functionality addition.\n', - 'relevant_tests': 'No\n', 'possible_issues': 'No\n', 'security_concerns': 'No\n'}} + input_data = {"review": { + "estimated_effort_to_review_[1-5]": ( + "1, because the changes are minimal and straightforward, " + "focusing on a single functionality addition.\n" + ), + "relevant_tests": "No\n", "possible_issues": "No\n", "security_concerns": "No\n"}} expected_output = textwrap.dedent("""\ ## PR Reviewer Guide 🔍 @@ -262,13 +268,17 @@ def test_contribution_time_cost_estimate(self): } } + contribution_row = ( + "
    ⏳ Contribution time estimate " + "(best, average, worst case): 1h | 2h | 30 minutes
    - + {contribution_row}
    ⏳ Contribution time estimate (best, average, worst case): 1h | 2h | 30 minutes
    """) assert convert_to_markdown_v2(input_data).strip() == expected_output.strip() @@ -363,7 +373,10 @@ def test_dictionary_with_empty_dictionaries(self): class TestBR: def test_br1(self): - file_change_description = '- Imported `FilePatchInfo` and `EDIT_TYPE` from `pr_agent.algo.types` instead of `pr_agent.git_providers.git_provider`.' + file_change_description = ( + "- Imported `FilePatchInfo` and `EDIT_TYPE` from `pr_agent.algo.types` " + "instead of `pr_agent.git_providers.git_provider`." + ) file_change_description_br = insert_br_after_x_chars(file_change_description) expected_output = ('
    • Imported FilePatchInfo and EDIT_TYPE from ' 'pr_agent.algo.types instead
      of ' @@ -385,11 +398,16 @@ def test_br2(self): # print(file_change_description_br) def test_br3(self): - file_change_description = 'Created a new class `ColorPaletteResourcesCollection` which extends `AvaloniaDictionary` and implements aaa' + file_change_description = ( + "Created a new class `ColorPaletteResourcesCollection` which extends " + "`AvaloniaDictionary` and implements aaa" + ) file_change_description_br = insert_br_after_x_chars(file_change_description) - assert file_change_description_br == ('Created a new class ColorPaletteResourcesCollection which ' - 'extends
      AvaloniaDictionary' - ' and implements
      aaa') + assert file_change_description_br == ( + "Created a new class ColorPaletteResourcesCollection which " + "extends
      AvaloniaDictionary" + " and implements
      aaa" + ) # print("-----") # print(file_change_description_br) diff --git a/tests/unittest/test_extend_patch.py b/tests/unittest/test_extend_patch.py index 9e01be962b..f9798671a5 100644 --- a/tests/unittest/test_extend_patch.py +++ b/tests/unittest/test_extend_patch.py @@ -56,7 +56,7 @@ def test_single_hunk(self): original_file_str = 'line1\nline2\nline3\nline4\nline5' patch_str = '@@ -2,3 +2,3 @@ init()\n-line2\n+new_line2\n line3\n line4' - for num_lines in [1, 2, 3]: # check that even if we are over the number of lines in the file, the function still works + for num_lines in [1, 2, 3]: # still works if extra lines exceed the file expected_output = '\n@@ -1,5 +1,5 @@ init()\n line1\n-line2\n+new_line2\n line3\n line4\n line5' actual_output = extend_patch(original_file_str, patch_str, patch_extra_lines_before=num_lines, patch_extra_lines_after=num_lines) @@ -93,21 +93,35 @@ def test_dynamic_context(self): num_lines=1 get_settings(use_context=False).config.allow_dynamic_context = True - actual_output = extend_patch(original_file_str, patch_str, - patch_extra_lines_before=num_lines, patch_extra_lines_after=num_lines, new_file_str=new_file_str) - expected_output='\n@@ -1,10 +1,10 @@ \n def foo():\n line(0)\n line(1)\n line(2)\n line(3)\n line(4)\n line(5)\n line(6)\n line(7)\n- line(8)\n+ new_line(8)' + actual_output = extend_patch( + original_file_str, + patch_str, + patch_extra_lines_before=num_lines, + patch_extra_lines_after=num_lines, + new_file_str=new_file_str, + ) + expected_output = ( + "\n@@ -1,10 +1,10 @@ \n def foo():\n line(0)\n line(1)\n" + " line(2)\n line(3)\n line(4)\n line(5)\n line(6)\n" + " line(7)\n- line(8)\n+ new_line(8)" + ) assert actual_output == expected_output get_settings(use_context=False).config.allow_dynamic_context = False actual_output2 = extend_patch(original_file_str, patch_str, patch_extra_lines_before=1, patch_extra_lines_after=1) - expected_output_no_dynamic_context = '\n@@ -9,2 +9,2 @@ def foo():\n line(7)\n- line(8)\n+ new_line(8)' + expected_output_no_dynamic_context = ( + "\n@@ -9,2 +9,2 @@ def foo():\n line(7)\n- line(8)\n+ new_line(8)" + ) assert actual_output2 == expected_output_no_dynamic_context get_settings(use_context=False).config.allow_dynamic_context = False actual_output3 = extend_patch(original_file_str, patch_str, patch_extra_lines_before=3, patch_extra_lines_after=3) - expected_output_no_dynamic_context = '\n@@ -7,4 +7,4 @@ def foo():\n line(5)\n line(6)\n line(7)\n- line(8)\n+ new_line(8)' + expected_output_no_dynamic_context = ( + "\n@@ -7,4 +7,4 @@ def foo():\n line(5)\n line(6)\n line(7)\n" + "- line(8)\n+ new_line(8)" + ) assert actual_output3 == expected_output_no_dynamic_context @@ -135,15 +149,34 @@ def pr_languages(self): # Create a list of languages with files containing base_file and patch data return [ { - 'files': [ - self.File(base_file="line000\nline00\nline0\nline1\noriginal content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10", - patch="@@ -5,5 +5,5 @@\n-original content\n+modified content\n line2\n line3\n line4\n line5", - head_file="line000\nline00\nline0\nline1\nmodified content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10", - filename="file1"), - self.File(base_file="original content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10", - patch="@@ -6,5 +6,5 @@\nline6\nline7\nline8\n-line9\n+modified line9\nline10", - head_file="original content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nmodified line9\nline10", - filename="file2") + "files": [ + self.File( + base_file=( + "line000\nline00\nline0\nline1\noriginal content\n" + "line2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10" + ), + patch=( + "@@ -5,5 +5,5 @@\n-original content\n+modified content\n" + " line2\n line3\n line4\n line5" + ), + head_file=( + "line000\nline00\nline0\nline1\nmodified content\n" + "line2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10" + ), + filename="file1", + ), + self.File( + base_file=( + "original content\nline2\nline3\nline4\nline5\n" + "line6\nline7\nline8\nline9\nline10" + ), + patch="@@ -6,5 +6,5 @@\nline6\nline7\nline8\n-line9\n+modified line9\nline10", + head_file=( + "original content\nline2\nline3\nline4\nline5\n" + "line6\nline7\nline8\nmodified line9\nline10" + ), + filename="file2", + ), ] } ] @@ -168,7 +201,10 @@ def test_extend_patches_with_extra_lines(self, token_handler, pr_languages): ) p0_extended = patches_extended_with_extra_lines[0].strip() - assert p0_extended == "## File: 'file1'\n\n@@ -3,8 +3,8 @@ \n line0\n line1\n-original content\n+modified content\n line2\n line3\n line4\n line5\n line6" + assert p0_extended == ( + "## File: 'file1'\n\n@@ -3,8 +3,8 @@ \n line0\n line1\n" + "-original content\n+modified content\n line2\n line3\n line4\n line5\n line6" + ) class TestLoadLargeDiff: def test_no_newline(self): diff --git a/tests/unittest/test_fetching_sub_issues.py b/tests/unittest/test_fetching_sub_issues.py index 0c48c1290c..020274533a 100644 --- a/tests/unittest/test_fetching_sub_issues.py +++ b/tests/unittest/test_fetching_sub_issues.py @@ -11,7 +11,11 @@ # class TestTicketCompliance(unittest.TestCase): # # @patch.object(GithubProvider, 'get_user_description', return_value="Fixes #1 and relates to #2") -# @patch.object(GithubProvider, '_parse_issue_url', side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split('#')[-1]))) +# @patch.object( +# GithubProvider, +# "_parse_issue_url", +# side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split("#")[-1])), +# ) # @patch.object(GithubProvider, 'repo_obj') # async def test_extract_tickets(self, mock_repo, mock_parse_issue_url, mock_user_desc): # """ @@ -49,7 +53,11 @@ # print("\n Test Passed: extract_tickets() successfully retrieved ticket info!") # # @patch.object(GithubProvider, 'get_user_description', return_value="Fixes #1 and relates to #2") -# @patch.object(GithubProvider, '_parse_issue_url', side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split('#')[-1]))) +# @patch.object( +# GithubProvider, +# "_parse_issue_url", +# side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split("#")[-1])), +# ) # @patch.object(GithubProvider, 'repo_obj') # async def test_extract_and_cache_pr_tickets(self, mock_repo, mock_parse_issue_url, mock_user_desc): # """ diff --git a/tests/unittest/test_file_filter.py b/tests/unittest/test_file_filter.py index b1719e4e88..05d27ad2d9 100644 --- a/tests/unittest/test_file_filter.py +++ b/tests/unittest/test_file_filter.py @@ -35,7 +35,10 @@ def test_glob_ignores(self, monkeypatch): ] filtered_files = filter_ignored(files) - assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + assert filtered_files == expected, ( + f"Expected {[file.filename for file in expected]}, " + f"but got {[file.filename for file in filtered_files]}." + ) def test_glob_ignores_dict_values(self, monkeypatch): """Verify ignore filtering for GitHub incremental dict_values views.""" @@ -68,7 +71,10 @@ def test_regex_ignores(self, monkeypatch): ] filtered_files = filter_ignored(files) - assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + assert filtered_files == expected, ( + f"Expected {[file.filename for file in expected]}, " + f"but got {[file.filename for file in filtered_files]}." + ) def test_invalid_regex(self, monkeypatch): """ @@ -89,7 +95,10 @@ def test_invalid_regex(self, monkeypatch): ] filtered_files = filter_ignored(files) - assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + assert filtered_files == expected, ( + f"Expected {[file.filename for file in expected]}, " + f"but got {[file.filename for file in filtered_files]}." + ) def test_language_framework_ignores(self, monkeypatch): """ diff --git a/tests/unittest/test_find_line_number_of_relevant_line_in_file.py b/tests/unittest/test_find_line_number_of_relevant_line_in_file.py index 18e1eb72e4..5a7c6a1000 100644 --- a/tests/unittest/test_find_line_number_of_relevant_line_in_file.py +++ b/tests/unittest/test_find_line_number_of_relevant_line_in_file.py @@ -5,60 +5,91 @@ class TestFindLineNumberOfRelevantLineInFile: - # Tests that the function returns the correct line number and absolute position when the relevant line is found in the patch + # Returns the correct line number and absolute position when the relevant line is in the patch def test_relevant_line_found_in_patch(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+line2\n+relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+line2\n+relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = 'relevant_line' expected = (3, 2) # (position in patch, absolute_position in new file) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns the correct line number and absolute position when a similar line is found using difflib + # Returns the correct line number when a similar line is found using difflib def test_similar_line_found_using_difflib(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line in file similar match\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line in file similar match\n", + filename="file1", + ) ] relevant_file = 'file1' - relevant_line_in_file = '+relevant_line in file similar match ' # note the space at the end. This is to simulate a similar line found using difflib + # trailing space simulates a similar line found using difflib + relevant_line_in_file = '+relevant_line in file similar match ' expected = (2, 1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant line is not found in the patch and no similar line is found using difflib + # Returns (-1, -1) when the relevant line is not in the patch and no similar line is found def test_relevant_line_not_found(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = 'not_found' expected = (-1, -1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant file is not found in any of the patches + # Returns (-1, -1) when the relevant file is not found in any of the patches def test_relevant_file_not_found(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n', filename='file2') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n", + filename="file2", + ) ] relevant_file = 'file1' relevant_line_in_file = 'relevant_line' expected = (-1, -1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant_line_in_file is an empty string + # Returns (-1, -1) when the relevant_line_in_file is an empty string def test_empty_relevant_line(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = '' expected = (-1, -1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant_line_in_file is found in the patch but it is a deleted line + # Returns (-1, -1) when the relevant line is found in the patch but deleted def test_relevant_line_found_but_deleted(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,2 +1,1 @@\n-line1\n-relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,2 +1,1 @@\n-line1\n-relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = 'relevant_line' diff --git a/tests/unittest/test_get_max_tokens.py b/tests/unittest/test_get_max_tokens.py index 1b8586d10c..01b35f41c7 100644 --- a/tests/unittest/test_get_max_tokens.py +++ b/tests/unittest/test_get_max_tokens.py @@ -966,7 +966,7 @@ def test_claude_model_family_metadata_key_validation(self): Proves: - all current shipped families validate successfully. - an unknown top-level key (e.g. 'bedrock_region') raises ValueError naming the key and model_id. - - an unknown nested extra_aliases key (e.g. 'extended_thinkin') raises ValueError naming the key, alias, and model_id. + - an unknown nested extra_aliases key (e.g. 'extended_thinkin') raises ValueError with key, alias, and model_id. - global family definitions are not mutated. """ # All current shipped families must pass validation diff --git a/tests/unittest/test_github_incremental_default.py b/tests/unittest/test_github_incremental_default.py index c3519c3dd4..22dc8a115f 100644 --- a/tests/unittest/test_github_incremental_default.py +++ b/tests/unittest/test_github_incremental_default.py @@ -112,7 +112,9 @@ def test_incremental_review_runs_when_commit_is_older_than_utc_threshold(): reviewer.pr_url = "https://github.com/test/repo/pull/1" commit = SimpleNamespace( - commit=SimpleNamespace(author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 12, 0, tzinfo=datetime.timezone.utc))) + commit=SimpleNamespace( + author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 12, 0, tzinfo=datetime.timezone.utc)) + ) ) reviewer.incremental = IncrementalPR(True) reviewer.incremental.commits_range = [commit] @@ -139,7 +141,9 @@ def test_incremental_review_skipped_when_commit_is_newer_than_utc_threshold(): reviewer.pr_url = "https://github.com/test/repo/pull/1" commit = SimpleNamespace( - commit=SimpleNamespace(author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 13, 15, tzinfo=datetime.timezone.utc))) + commit=SimpleNamespace( + author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 13, 15, tzinfo=datetime.timezone.utc)) + ) ) reviewer.incremental = IncrementalPR(True) reviewer.incremental.commits_range = [commit] diff --git a/tests/unittest/test_github_webhook_delivery.py b/tests/unittest/test_github_webhook_delivery.py index 5826e30ebe..f6abf0c279 100644 --- a/tests/unittest/test_github_webhook_delivery.py +++ b/tests/unittest/test_github_webhook_delivery.py @@ -168,7 +168,9 @@ async def dispatch(body, _event, _action): try: await asyncio.wait_for(entered.wait(), 1) await asyncio.wait_for( - github_app.handle_request({"action": "created", "comment_id": 2}, "issue_comment", delivery_id="delivery-2"), + github_app.handle_request( + {"action": "created", "comment_id": 2}, "issue_comment", delivery_id="delivery-2" + ), 1, ) assert dispatch_mock.await_count == 2 diff --git a/tests/unittest/test_gitlab_diffs_api.py b/tests/unittest/test_gitlab_diffs_api.py index 22d70d8d37..e571a3effd 100644 --- a/tests/unittest/test_gitlab_diffs_api.py +++ b/tests/unittest/test_gitlab_diffs_api.py @@ -132,7 +132,9 @@ def test_numeric_project_identifier(provider_factory, project_id): @pytest.mark.parametrize("flag", ["too_large", "collapsed"]) def test_pruned_patch_on_later_page_is_reconstructed_from_both_blobs(provider_factory, flag): - provider, transport = provider_factory(_pages([_change("visible.py")], [_change("pruned.py", diff="", **{flag: True})])) + provider, transport = provider_factory( + _pages([_change("visible.py")], [_change("pruned.py", diff="", **{flag: True})]) + ) files = provider.get_diff_files() diff --git a/tests/unittest/test_litellm_api_key_guard.py b/tests/unittest/test_litellm_api_key_guard.py index 7f048946f5..dad6d81f07 100644 --- a/tests/unittest/test_litellm_api_key_guard.py +++ b/tests/unittest/test_litellm_api_key_guard.py @@ -814,7 +814,9 @@ async def test_native_azure_oidc_bridge_responses(monkeypatch, native_azure_oidc monkeypatch.setattr(litellm, "enable_azure_ad_token_refresh", True) provider = MagicMock(side_effect=AssertionError("OIDC must not create an additional credential provider")) monkeypatch.setattr(azure_common, "get_azure_ad_token_provider", provider) - assert await state.invoke(handler, guard_key=guard_key, model="azure/responses/gpt-4o") == "Bearer exchanged-token-1" + assert await state.invoke( + handler, guard_key=guard_key, model="azure/responses/gpt-4o" + ) == "Bearer exchanged-token-1" assert len(state.exchanged) == 1 url, data = state.exchanged[0] assert url == "https://owned-authority.example/owned-tenant/oauth2/v2.0/token" @@ -1012,7 +1014,9 @@ async def test_native_azure_ad_sdk_companion( @pytest.mark.parametrize("guard_key", (False, True)) @pytest.mark.parametrize("entrypoint", ("chat", "probe")) @pytest.mark.asyncio -async def test_native_azure_ad_sdk_rejects_implicit_refresh(monkeypatch, native_azure_oidc, transport, guard_key, entrypoint): +async def test_native_azure_ad_sdk_rejects_implicit_refresh( + monkeypatch, native_azure_oidc, transport, guard_key, entrypoint +): import azure.identity state = native_azure_oidc @@ -1226,7 +1230,9 @@ async def completion(**kwargs): @pytest.mark.parametrize("host", ("https://owned.example", "https://owned.services.ai.azure.com")) @pytest.mark.parametrize("headers", ({}, {"api-key": "gateway-key"}, {"Authorization": "Basic explicit"})) @pytest.mark.asyncio -async def test_native_raw_azure_companion_snapshot(monkeypatch, native_azure_oidc, native_companion_auth, host, headers): +async def test_native_raw_azure_companion_snapshot( + monkeypatch, native_azure_oidc, native_companion_auth, host, headers +): import azure.identity state = native_azure_oidc @@ -1356,7 +1362,9 @@ async def test_ordinary_azure_sdk_context_does_not_select_oidc(monkeypatch, nati litellm_handler._install_azure_oidc_bridge() monkeypatch.setattr(litellm, "enable_azure_ad_token_refresh", False) - monkeypatch.setattr(azure_common, "get_secret_str", lambda name: "outside-token" if name == "AZURE_AD_TOKEN" else None) + monkeypatch.setattr( + azure_common, "get_secret_str", lambda name: "outside-token" if name == "AZURE_AD_TOKEN" else None + ) context = litellm_handler._azure_oidc_request.set({ "selector": None, "dispatch_token": "owned-token", "generated_guard": True, }) @@ -4354,7 +4362,9 @@ async def completion(**kwargs): (None, {"GOOGLE_CLOUD_PROJECT": "", "GCLOUD_PROJECT": "legacy"}, None), )) @pytest.mark.asyncio -async def test_vertex_project_snapshot_preserves_explicit_project_precedence(monkeypatch, setting, environment, expected): +async def test_vertex_project_snapshot_preserves_explicit_project_precedence( + monkeypatch, setting, environment, expected +): monkeypatch.setattr(litellm_handler, "get_settings", lambda: _make_settings({"VERTEXAI.VERTEX_PROJECT": setting})) for variable, value in environment.items(): monkeypatch.setenv(variable, value) @@ -5607,7 +5617,9 @@ async def test_vertex_wif_failure_does_not_cache_or_fallback(monkeypatch, failur info = _external_vertex_adc("123", "identity_pool", None) credentials = MagicMock() - credentials.get_project_id.return_value = None if failure == "missing" else 123 if failure == "wrong_type" else "project" + credentials.get_project_id.return_value = ( + None if failure == "missing" else 123 if failure == "wrong_type" else "project" + ) if failure == "discovery": credentials.get_project_id.side_effect = RuntimeError("discovery failed") vertex = VertexBase() @@ -5620,7 +5632,9 @@ async def test_vertex_wif_failure_does_not_cache_or_fallback(monkeypatch, failur async def completion(**kwargs): return await vertex.get_access_token_async(kwargs["vertex_credentials"], None) - expected = {"missing": ValueError, "wrong_type": TypeError, "discovery": RuntimeError, "refresh": RuntimeError}[failure] + expected = { + "missing": ValueError, "wrong_type": TypeError, "discovery": RuntimeError, "refresh": RuntimeError, + }[failure] with pytest.raises(expected): await LiteLLMAIHandler()._acompletion( _completion=completion, model="vertex_ai/gemini-2.5-pro", vertex_credentials=json.dumps(info), @@ -7989,7 +8003,9 @@ async def send(client, request, **kwargs): "us-west-2", "https://mantle.example/v1"), )) @pytest.mark.asyncio -async def test_native_mantle_region(monkeypatch, auth, entrypoint, selection, environment, expected_region, expected_base): +async def test_native_mantle_region( + monkeypatch, auth, entrypoint, selection, environment, expected_region, expected_base +): for name in ("AWS_REGION_NAME", "AWS_REGION", "AWS_DEFAULT_REGION", "DEFAULT_REGION", "BEDROCK_MANTLE_REGION"): monkeypatch.delenv(name, raising=False) monkeypatch.setenv("AWS_USE_IMDS", "false") diff --git a/tests/unittest/test_litellm_databricks_provider.py b/tests/unittest/test_litellm_databricks_provider.py index 3ef0b1e6c1..fa59693cbd 100644 --- a/tests/unittest/test_litellm_databricks_provider.py +++ b/tests/unittest/test_litellm_databricks_provider.py @@ -150,7 +150,9 @@ async def test_databricks_does_not_receive_another_provider_credentials(monkeypa @pytest.mark.parametrize("entrypoint", ("chat_completion", "probe_completion")) -@pytest.mark.parametrize("key_source", ("global_before", "global_after", "provider_global", "captured_pat", "literal_dummy")) +@pytest.mark.parametrize( + "key_source", ("global_before", "global_after", "provider_global", "captured_pat", "literal_dummy") +) @pytest.mark.asyncio async def test_databricks_native_oauth_is_not_overwritten_by_foreign_keys(monkeypatch, entrypoint, key_source): from litellm.litellm_core_utils import logging_worker diff --git a/tests/unittest/test_litellm_imds.py b/tests/unittest/test_litellm_imds.py index 6743624158..ea68da9e6a 100644 --- a/tests/unittest/test_litellm_imds.py +++ b/tests/unittest/test_litellm_imds.py @@ -1292,7 +1292,9 @@ async def test_static_mode_rejects_late_aws_request_endpoint_environment( @pytest.mark.parametrize("environment_variable", ("AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE")) @pytest.mark.asyncio -async def test_imds_mode_rejects_changed_credential_chain_file(monkeypatch, aws_session, tmp_path, environment_variable): +async def test_imds_mode_rejects_changed_credential_chain_file( + monkeypatch, aws_session, tmp_path, environment_variable +): credential_chain_file = tmp_path / environment_variable.lower() credential_chain_file.write_text("request-a", encoding="utf-8") monkeypatch.setenv("AWS_USE_IMDS", "true") diff --git a/tests/unittest/test_load_yaml.py b/tests/unittest/test_load_yaml.py index 74f2b57de3..d61f1f1c5b 100644 --- a/tests/unittest/test_load_yaml.py +++ b/tests/unittest/test_load_yaml.py @@ -17,25 +17,51 @@ def test_load_valid_yaml(self): assert load_yaml(yaml_str) == expected_output def test_load_invalid_yaml1(self): - yaml_str = \ -'''\ -PR Analysis: - Main theme: Enhancing the `/describe` command prompt by adding title and description - Type of PR: Enhancement - Relevant tests: No - Focused PR: Yes, the PR is focused on enhancing the `/describe` command prompt. - -PR Feedback: - General suggestions: The PR seems to be well-structured and focused on a specific enhancement. However, it would be beneficial to add tests to ensure the new feature works as expected. - Code feedback: - - relevant file: pr_agent/settings/pr_description_prompts.toml - suggestion: Consider using a more descriptive variable name than 'user' for the command prompt. A more descriptive name would make the code more readable and maintainable. [medium] - relevant line: user="""PR Info: aaa - Security concerns: No''' + yaml_str = ( + "PR Analysis:\n" + " Main theme: Enhancing the `/describe` command prompt by adding title and description\n" + " Type of PR: Enhancement\n" + " Relevant tests: No\n" + " Focused PR: Yes, the PR is focused on enhancing the `/describe` command prompt.\n" + "\n" + "PR Feedback:\n" + " General suggestions: The PR seems to be well-structured and focused on a specific " + "enhancement. However, it would be beneficial to add tests to ensure the new feature " + "works as expected.\n" + " Code feedback:\n" + " - relevant file: pr_agent/settings/pr_description_prompts.toml\n" + " suggestion: Consider using a more descriptive variable name than 'user' for the " + "command prompt. A more descriptive name would make the code more readable and " + "maintainable. [medium]\n" + ' relevant line: user="""PR Info: aaa\n' + " Security concerns: No" + ) with pytest.raises(ScannerError): yaml.safe_load(yaml_str) - expected_output = {'PR Analysis': {'Main theme': 'Enhancing the `/describe` command prompt by adding title and description', 'Type of PR': 'Enhancement', 'Relevant tests': False, 'Focused PR': 'Yes, the PR is focused on enhancing the `/describe` command prompt.'}, 'PR Feedback': {'General suggestions': 'The PR seems to be well-structured and focused on a specific enhancement. However, it would be beneficial to add tests to ensure the new feature works as expected.', 'Code feedback': [{'relevant file': 'pr_agent/settings/pr_description_prompts.toml\n', 'suggestion': "Consider using a more descriptive variable name than 'user' for the command prompt. A more descriptive name would make the code more readable and maintainable. [medium]", 'relevant line': 'user="""PR Info: aaa\n'}], 'Security concerns': False}} + expected_output = { + "PR Analysis": { + "Main theme": "Enhancing the `/describe` command prompt by adding title and description", + "Type of PR": "Enhancement", + "Relevant tests": False, + "Focused PR": "Yes, the PR is focused on enhancing the `/describe` command prompt.", + }, + "PR Feedback": { + "General suggestions": ( + "The PR seems to be well-structured and focused on a specific enhancement. " + "However, it would be beneficial to add tests to ensure the new feature works as expected." + ), + "Code feedback": [{ + "relevant file": "pr_agent/settings/pr_description_prompts.toml\n", + "suggestion": ( + "Consider using a more descriptive variable name than 'user' for the command prompt. " + "A more descriptive name would make the code more readable and maintainable. [medium]" + ), + "relevant line": 'user="""PR Info: aaa\n', + }], + "Security concerns": False, + }, + } assert load_yaml(yaml_str) == expected_output def test_load_invalid_yaml2(self): @@ -46,7 +72,10 @@ def test_load_invalid_yaml2(self): with pytest.raises(ScannerError): yaml.safe_load(yaml_str) - expected_output = [{'relevant file': 'src/app.py:\n', 'suggestion content': 'The print statement is outside inside the if __name__ ==:'}] + expected_output = [{ + "relevant file": "src/app.py:\n", + "suggestion content": "The print statement is outside inside the if __name__ ==:", + }] assert load_yaml(yaml_str) == expected_output def test_load_yaml_with_illegal_control_character(self): diff --git a/tests/unittest/test_output_models.py b/tests/unittest/test_output_models.py index cbed2e496a..cc97c4776a 100644 --- a/tests/unittest/test_output_models.py +++ b/tests/unittest/test_output_models.py @@ -111,11 +111,19 @@ def _review_fixture(): "relevant file": "src/app.py", "relevant line": 12, "doc placement": "after", "documentation": "Document the handler.", }]}), - (PRRankResponses, {"which_response_was_better": 1, "why": "It is clearer.", "score_response1": 9, "score_response2": 7}), + (PRRankResponses, { + "which_response_was_better": 1, + "why": "It is clearer.", + "score_response1": 9, + "score_response2": 7, + }), (DocHelper, {"user_question": "How?", "response": "Use the helper.", "relevant_sections": [{ "file_name": "docs/guide.md", "relevant_section_header_string": "## Usage", }], "question_is_relevant": 1}), - (DocHeadingsHelper, {"user_question": "How?", "relevant_files_ranking": [{"idx": 0, "file_name": "docs/guide.md"}]}), + (DocHeadingsHelper, { + "user_question": "How?", + "relevant_files_ranking": [{"idx": 0, "file_name": "docs/guide.md"}], + }), ], ) def test_output_models_validate_complete_fixtures(model, payload): @@ -158,7 +166,10 @@ def test_labels_reject_unusable_model_shapes(payload): def test_review_alias_accepts_prompt_field_name(): - assert Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review_[1-5]": 3}).estimated_effort_to_review == 3 + assert Review.model_validate({ + "key_issues_to_review": [], + "estimated_effort_to_review_[1-5]": 3, + }).estimated_effort_to_review == 3 with pytest.raises(ValueError): Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review": 3}) @@ -189,7 +200,9 @@ def test_required_label_and_list_constraints_are_enforced(): with pytest.raises(ValueError): CodeSuggestion.model_validate(suggestion) with pytest.raises(ValueError): - PRRankResponses.model_validate({"which_response_was_better": 3, "why": "No", "score_response1": 1, "score_response2": 1}) + PRRankResponses.model_validate({ + "which_response_was_better": 3, "why": "No", "score_response1": 1, "score_response2": 1, + }) with pytest.raises(ValueError): Review.model_validate({"key_issues_to_review": [], "can_be_split": [{"relevant_files": [], "title": "x"}] * 4}) with pytest.raises(ValueError): @@ -206,11 +219,17 @@ def test_required_label_and_list_constraints_are_enforced(): "relevant_lines_end": 1, "suggestion_score": 11, "why": "x", }]}) with pytest.raises(ValueError): - PRRankResponses.model_validate({"which_response_was_better": 1, "why": "x", "score_response1": 0, "score_response2": 11}) + PRRankResponses.model_validate({ + "which_response_was_better": 1, "why": "x", "score_response1": 0, "score_response2": 11, + }) with pytest.raises(ValueError): - DocHelper.model_validate({"user_question": "x", "response": "x", "relevant_sections": [], "question_is_relevant": 2}) + DocHelper.model_validate({ + "user_question": "x", "response": "x", "relevant_sections": [], "question_is_relevant": 2, + }) with pytest.raises(ValueError): - DocHeadingsHelper.model_validate({"user_question": "x", "relevant_files_ranking": [{"idx": -1, "file_name": "x"}]}) + DocHeadingsHelper.model_validate({ + "user_question": "x", "relevant_files_ranking": [{"idx": -1, "file_name": "x"}], + }) with pytest.raises(ValueError): Review.model_validate({"key_issues_to_review": [], "risk_level": "critical"}) with pytest.raises(ValueError): @@ -293,15 +312,24 @@ def _model_type_signature(annotation): "PRReview": PRReview}, "pr_description_prompts.toml": {"FileDescription": FileDescription, "PRDescription": PRDescription}, "pr_description_only_description_prompts.toml": {"PRDescriptionHeaders": PRDescriptionHeaders}, - "pr_description_only_files_prompts.toml": {"FileDescription": FileDescription, "PRFilesWalkthrough": PRFilesWalkthrough}, + "pr_description_only_files_prompts.toml": { + "FileDescription": FileDescription, "PRFilesWalkthrough": PRFilesWalkthrough, + }, "pr_custom_labels.toml": {"Labels": Labels}, "pr_evaluate_prompt_response.toml": {"PRRankResponses": PRRankResponses}, "pr_help_prompts.toml": {"relevant_section": RelevantSection, "DocHelper": DocHelper}, "pr_help_docs_prompts.toml": {"relevant_section": RelevantSection, "DocHelper": DocHelper}, "pr_help_docs_headings_prompts.toml": {"file_idx_and_path": FileIdxAndPath, "DocHeadingsHelper": DocHeadingsHelper}, - "code_suggestions/pr_code_suggestions_prompts.toml": {"CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions}, - "code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml": {"CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions}, - "code_suggestions/pr_code_suggestions_reflect_prompts.toml": {"CodeSuggestionFeedback": CodeSuggestionFeedback, "PRCodeSuggestionsFeedback": PRCodeSuggestionsFeedback}, + "code_suggestions/pr_code_suggestions_prompts.toml": { + "CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions, + }, + "code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml": { + "CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions, + }, + "code_suggestions/pr_code_suggestions_reflect_prompts.toml": { + "CodeSuggestionFeedback": CodeSuggestionFeedback, + "PRCodeSuggestionsFeedback": PRCodeSuggestionsFeedback, + }, } @@ -323,8 +351,10 @@ def test_prompt_fields_are_present_in_output_models(): for field_name, annotation in re.findall( r"^ ([A-Za-z_][A-Za-z0-9_\[\]-]*):\s*([^=]+)", block, re.MULTILINE ): - field = next((value for value in model.model_fields.values() - if value.alias == field_name or value.alias is None and value.validation_alias == field_name), None) + field = next(( + value for value in model.model_fields.values() + if value.alias == field_name or value.alias is None and value.validation_alias == field_name + ), None) if field is None: field = model.model_fields.get(field_name) assert field is not None, f"{relative_path}: {class_name} field {field_name} is missing" diff --git a/tests/unittest/test_per_directory_settings.py b/tests/unittest/test_per_directory_settings.py index b6979afbc1..45af5443a6 100644 --- a/tests/unittest/test_per_directory_settings.py +++ b/tests/unittest/test_per_directory_settings.py @@ -1071,7 +1071,10 @@ def test_get_repo_settings_tree_falls_back_to_default_on_404(self): assert resolved_ref == "main" assert paths == ["svc/.pr_agent.toml"] - assert repo_obj.get_git_tree.call_args_list == [call("missing-branch", recursive=True), call("main", recursive=True)] + assert repo_obj.get_git_tree.call_args_list == [ + call("missing-branch", recursive=True), + call("main", recursive=True), + ] def test_get_repo_settings_tree_surfaces_unexpected_errors(self): repo_obj = MagicMock() diff --git a/tests/unittest/test_pr_code_suggestions_pr_chat.py b/tests/unittest/test_pr_code_suggestions_pr_chat.py index 0c141f591c..866b3f6ef3 100644 --- a/tests/unittest/test_pr_code_suggestions_pr_chat.py +++ b/tests/unittest/test_pr_code_suggestions_pr_chat.py @@ -60,7 +60,9 @@ def get_repo_settings(self): def publish_comment(self, pr_comment: str, is_temporary: bool = False): pass - def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): + def publish_inline_comment( + self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None + ): pass def publish_inline_comments(self, comments: list[dict]): diff --git a/tests/unittest/test_pr_description_lifecycle.py b/tests/unittest/test_pr_description_lifecycle.py index 41f5a4f433..a2280f1116 100644 --- a/tests/unittest/test_pr_description_lifecycle.py +++ b/tests/unittest/test_pr_description_lifecycle.py @@ -219,7 +219,9 @@ async def test_run_reports_description_publication_failure(monkeypatch, propagat provider.publish_description.assert_called_once() assert call("Failed to update PR description") in provider.publish_comment.call_args_list - assert not any("updated to latest commit" in str(published) for published in provider.publish_comment.call_args_list) + assert not any( + "updated to latest commit" in str(published) for published in provider.publish_comment.call_args_list + ) provider.remove_comment.assert_called_once_with(progress_comment) finally: restore_settings(settings_snapshot) diff --git a/tests/unittest/test_pr_reviewer_finding_state.py b/tests/unittest/test_pr_reviewer_finding_state.py index ab9940f842..b04bd56180 100644 --- a/tests/unittest/test_pr_reviewer_finding_state.py +++ b/tests/unittest/test_pr_reviewer_finding_state.py @@ -813,7 +813,9 @@ async def test_invalid_history_updates_persistent_comment_without_fallback(monke reviewer._review_state_result = None reviewer._review_state_blocked = True provider.get_issue_comments.return_value = [ - SimpleNamespace(body=f"{PRReviewHeader.REGULAR.value} 🔍\n\nold review\n\n") + SimpleNamespace( + body=f"{PRReviewHeader.REGULAR.value} 🔍\n\nold review\n\n" + ) ] reviewer._load_review_finding_state() diff --git a/tests/unittest/test_pr_update_changelog.py b/tests/unittest/test_pr_update_changelog.py index 16cfca2b01..651634fdd7 100644 --- a/tests/unittest/test_pr_update_changelog.py +++ b/tests/unittest/test_pr_update_changelog.py @@ -51,7 +51,9 @@ def changelog_tool(self, mock_git_provider, mock_ai_handler): mock_settings.return_value.pr_update_changelog_prompt.user = "User prompt" mock_settings.return_value.config.temperature = 0.2 - tool = PRUpdateChangelog("https://gitlab.com/test/repo/-/merge_requests/1", ai_handler=lambda: mock_ai_handler) + tool = PRUpdateChangelog( + "https://gitlab.com/test/repo/-/merge_requests/1", ai_handler=lambda: mock_ai_handler + ) return tool def test_get_changelog_file_with_existing_content(self, changelog_tool, mock_git_provider): diff --git a/tests/unittest/test_progress_comment.py b/tests/unittest/test_progress_comment.py index e4be03a337..e8542b134d 100644 --- a/tests/unittest/test_progress_comment.py +++ b/tests/unittest/test_progress_comment.py @@ -86,4 +86,7 @@ def test_build_progress_comment_uses_defaults(mock_get_settings): progress_comment = build_progress_comment() - assert f'Work in progress' in progress_comment + assert ( + f'Work in progress' + in progress_comment + ) diff --git a/tests/unittest/test_register_git_provider.py b/tests/unittest/test_register_git_provider.py index 647918e16a..0f54036eec 100644 --- a/tests/unittest/test_register_git_provider.py +++ b/tests/unittest/test_register_git_provider.py @@ -58,7 +58,9 @@ def get_repo_settings(self): def publish_comment(self, pr_comment: str, is_temporary: bool = False): pass - def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): + def publish_inline_comment( + self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None + ): pass def publish_inline_comments(self, comments: list[dict]): diff --git a/tests/unittest/test_secret_provider_factory.py b/tests/unittest/test_secret_provider_factory.py index edd1d015c9..6fad0147a0 100644 --- a/tests/unittest/test_secret_provider_factory.py +++ b/tests/unittest/test_secret_provider_factory.py @@ -54,7 +54,9 @@ def test_get_secret_provider_google_cloud_storage(self): settings.config.secret_provider = "google_cloud_storage" mock_get_settings.return_value = settings - with patch('pr_agent.secret_providers.google_cloud_storage_secret_provider.GoogleCloudStorageSecretProvider') as MockProvider: + with patch( + "pr_agent.secret_providers.google_cloud_storage_secret_provider.GoogleCloudStorageSecretProvider" + ) as MockProvider: mock_instance = MagicMock() MockProvider.return_value = mock_instance @@ -69,7 +71,9 @@ def test_get_secret_provider_aws_secrets_manager(self): settings.config.secret_provider = "aws_secrets_manager" mock_get_settings.return_value = settings - with patch('pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider') as MockProvider: + with patch( + "pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider" + ) as MockProvider: mock_instance = MagicMock() MockProvider.return_value = mock_instance @@ -94,7 +98,9 @@ def test_get_secret_provider_initialization_error(self): settings.config.secret_provider = "aws_secrets_manager" mock_get_settings.return_value = settings - with patch('pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider') as MockProvider: + with patch( + "pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider" + ) as MockProvider: MockProvider.side_effect = Exception("Initialization failed") with pytest.raises(ValueError, match="Failed to initialize aws_secrets_manager secret provider"): diff --git a/tests/unittest/test_similar_issue_qdrant_collection.py b/tests/unittest/test_similar_issue_qdrant_collection.py index 977f5645fe..7c1c8cc406 100644 --- a/tests/unittest/test_similar_issue_qdrant_collection.py +++ b/tests/unittest/test_similar_issue_qdrant_collection.py @@ -25,7 +25,9 @@ def test_suffix_is_scoped_to_qdrant_only(): assert 'index_name = self.index_name = "codium-ai-pr-agent-issues"' in source assert "self.pc.Index(name=self.index_name)" in source assert "self.db.create_table(self.index_name, data=df, mode=\"overwrite\")" in source - assert "self.qdrant_collection_name" not in source.split("elif get_settings().pr_similar_issue.vectordb == \"qdrant\":")[0] + assert "self.qdrant_collection_name" not in source.split( + "elif get_settings().pr_similar_issue.vectordb == \"qdrant\":" + )[0] def test_docs_carry_the_upgrade_note(): diff --git a/tests/unittest/test_telemetry_prometheus.py b/tests/unittest/test_telemetry_prometheus.py index 3d6ecab7f1..0cb6637062 100644 --- a/tests/unittest/test_telemetry_prometheus.py +++ b/tests/unittest/test_telemetry_prometheus.py @@ -62,7 +62,10 @@ def test_multiple_workers_aggregate_at_scrape_time(): "from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader", "from pr_agent.telemetry.prometheus import PrometheusMetricExporter", "provider = MeterProvider(metric_readers=[PeriodicExportingMetricReader(PrometheusMetricExporter())])", - "counter = provider.get_meter('mp-test').create_counter('pr_agent.commands', unit='{command}', description='PR-Agent commands executed')", + ( + "counter = provider.get_meter('mp-test').create_counter(" + "'pr_agent.commands', unit='{command}', description='PR-Agent commands executed')" + ), "counter.add(1, {'pr_agent.command': 'review'})", "provider.shutdown()", ]) diff --git a/tests/unittest/test_try_fix_yaml.py b/tests/unittest/test_try_fix_yaml.py index 35f2dc80d8..e3130a86a2 100644 --- a/tests/unittest/test_try_fix_yaml.py +++ b/tests/unittest/test_try_fix_yaml.py @@ -158,7 +158,12 @@ def test_no_initial_yaml(self): We can further improve the code by using the `const` keyword instead of `var` in the `src/index.ts` file. ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output @@ -181,7 +186,12 @@ def test_with_initial_yaml(self): We can further improve the code by using the `const` keyword instead of `var` in the `src/index.ts` file. ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output @@ -200,7 +210,12 @@ def test_with_brackets_yaml_content(self): enhancement } ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output def test_tab_indent_yaml(self): @@ -216,7 +231,12 @@ def test_tab_indent_yaml(self): label: | enhancement ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement\n'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement\n"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output @@ -410,7 +430,15 @@ def test_wrong_indentation_code_block_scalar(self): return a - b; } ''' - expected_output = {'code_suggestions': [{'relevant_file': 'a.c\n', 'existing_code': ' int sum(int a, int b) {\n return a + b;\n }\n\n int sub(int a, int b) {\n return a - b;\n }\n'}]} + expected_output = { + "code_suggestions": [{ + "relevant_file": "a.c\n", + "existing_code": ( + " int sum(int a, int b) {\n return a + b;\n }\n\n" + " int sub(int a, int b) {\n return a - b;\n }\n" + ), + }] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='existing_code') == expected_output def test_diff_markers_removed_within_list_item(self): From 8a578ef56ac474c01085f072f8721a51c263677e Mon Sep 17 00:00:00 2001 From: Azhar Khan Date: Mon, 21 Sep 2026 13:53:03 +0530 Subject: [PATCH 218/238] refactor(algo): extract run reporting to run_output (3/3) (#3511) (#3522) --- .../algo/ai_handlers/litellm_ai_handler.py | 3 +- pr_agent/algo/run_output.py | 257 ++++++++++++++++++ pr_agent/algo/utils.py | 244 +---------------- pr_agent/cli.py | 2 +- pr_agent/git_providers/local_git_provider.py | 2 +- pr_agent/git_providers/plain_diff_provider.py | 2 +- pr_agent/mosaico/card.py | 2 +- pr_agent/telemetry/config.py | 2 +- pr_agent/tools/pr_code_suggestions.py | 4 +- pr_agent/tools/pr_description.py | 4 +- pr_agent/tools/pr_reviewer.py | 10 +- pr_agent/tools/pr_update_changelog.py | 3 +- tests/unittest/test_config_dead_keys.py | 12 +- tests/unittest/test_config_yaml_rendering.py | 2 +- tests/unittest/test_get_version.py | 6 +- tests/unittest/test_github_action_output.py | 3 +- tests/unittest/test_push_outputs.py | 51 ++-- .../test_show_relevant_configurations.py | 2 +- tests/unittest/test_show_run_details.py | 2 +- tests/unittest/test_telemetry_config.py | 2 +- 20 files changed, 317 insertions(+), 298 deletions(-) create mode 100644 pr_agent/algo/run_output.py diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 78891ce69d..f4d6b5db41 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -133,7 +133,8 @@ get_repetition_penalty, ) from pr_agent.algo.run_details import _as_decimal_cost, record_ai_call -from pr_agent.algo.utils import ReasoningEffort, get_version +from pr_agent.algo.run_output import get_version +from pr_agent.algo.utils import ReasoningEffort from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.log import get_logger diff --git a/pr_agent/algo/run_output.py b/pr_agent/algo/run_output.py new file mode 100644 index 0000000000..c7feab1401 --- /dev/null +++ b/pr_agent/algo/run_output.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone +from decimal import ROUND_HALF_UP, Decimal +from importlib.metadata import PackageNotFoundError, version +from urllib.parse import urlparse + +import requests +import yaml + +from pr_agent.algo.run_details import get_run_details +from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger + + +def github_action_output(output_data: dict, key_name: str): + try: + enable_output = get_settings().get("github_action_config.enable_output", False) + if isinstance(enable_output, str): + enable_output = enable_output.lower().strip() not in ("false", "0", "no", "") + if not enable_output: + return + + key_data = output_data.get(key_name, {}) + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + print(f"{key_name}={json.dumps(key_data, indent=None, ensure_ascii=False)}", file=fh) + except Exception as e: + get_logger().error(f"Failed to write to GitHub Action output: {e}") + return + + +def _push_outputs_sink_url(cfg: dict, key: str) -> str: + """Return cfg[key] if it is an absolute https URL with a host, else "" (with a warning). + + Requiring https keeps the review text, which can quote private code, off plaintext + transports. The host is not restricted: self-hosted collectors and Slack-compatible + endpoints (Mattermost, Rocket.Chat) are legitimate targets. + """ + url = cfg.get(key) or "" + if not url: + return "" + parsed = urlparse(url) + if parsed.scheme != "https" or not parsed.hostname: + # Log the key, never the value: a webhook URL is itself the credential. + get_logger().warning(f"push_outputs: ignoring {key}, expected an absolute https:// URL") + return "" + return url + + +def push_outputs(message_type: str, payload: dict | None = None, markdown: str | None = None) -> None: + """Emit a tool's output to external sinks, without calling any git-provider API. + + Controlled by the [push_outputs] config section (disabled by default). Supported channels: + "stdout" (one JSON line), "file" (append JSONL), "webhook" (POST the generic record), + "slack" (POST {"text": ...} to a Slack Incoming Webhook). Non-fatal: never raises. + """ + try: + cfg = get_settings().get("push_outputs", {}) or {} + enable = cfg.get("enable", False) + # Treat environment-variable strings "false", "0", "no", and "" as disabled. + if isinstance(enable, str): + enable = enable.lower().strip() not in ("false", "0", "no", "") + if not enable: + return + + channels = cfg.get("channels", []) or [] + record = { + "type": message_type, + "timestamp": datetime.now(timezone.utc).isoformat(), + "payload": payload or {}, + } + if markdown is not None: + record["markdown"] = markdown + + if "stdout" in channels: + try: + print(json.dumps(record, ensure_ascii=False)) + except Exception as e: + get_logger().warning(f"push_outputs: stdout failed: {type(e).__name__}") + + if "file" in channels: + try: + file_path = cfg.get("file_path", "pr-agent-outputs/reviews.jsonl") + folder = os.path.dirname(file_path) + if folder: + os.makedirs(folder, exist_ok=True) + with open(file_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + except Exception as e: + get_logger().warning(f"push_outputs: file failed: {type(e).__name__}") + + # Write local channels first and network last so a failed POST cannot drop a file write. + # Never follow a redirect from a configured sink to another host (allow_redirects=False). + if "webhook" in channels: + try: + webhook_url = _push_outputs_sink_url(cfg, "webhook_url") + if webhook_url: + response = requests.post(webhook_url, json=record, timeout=5, allow_redirects=False) + if not 200 <= response.status_code < 300: + get_logger().warning(f"push_outputs: webhook failed with status {response.status_code}") + except Exception as e: + get_logger().warning(f"push_outputs: webhook failed: {type(e).__name__}") + + # Post {"text": ...} directly to Slack Incoming Webhooks without a relay service. + if "slack" in channels: + try: + slack_webhook_url = _push_outputs_sink_url(cfg, "slack_webhook_url") + if slack_webhook_url: + text = markdown if markdown is not None else json.dumps(payload or {}, ensure_ascii=False) + response = requests.post(slack_webhook_url, json={"text": text}, timeout=5, + allow_redirects=False) + if not 200 <= response.status_code < 300: + get_logger().warning(f"push_outputs: slack failed with status {response.status_code}") + except Exception as e: + get_logger().warning(f"push_outputs: slack failed: {type(e).__name__}") + except Exception as e: + # Log only the exception type: requests errors embed the (secret-bearing) URL in their text. + get_logger().warning(f"push_outputs failed: {type(e).__name__}") + + +def _render_setting_value(value) -> str: + """Render a settings value as YAML, so nested values do not become Python reprs.""" + if isinstance(value, (str, int, float, bool)) or value is None: + return str(value) + try: + return yaml.safe_dump(_to_plain(value), default_flow_style=True).strip() + except Exception: + return str(value) + + +def _to_plain(value): + """Convert Dynaconf boxes to plain dict/list so yaml can represent them.""" + if isinstance(value, dict): + return {str(k): _to_plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_to_plain(v) for v in value] + return value + + +def show_relevant_configurations(relevant_section: str) -> str: + skip_keys = [ + "ai_disclaimer", "ai_disclaimer_title", "ANALYTICS_FOLDER", "secret_provider", + "skip_keys", "app_id", "redirect", "trial_prefix_message", "no_eligible_message", + "identity_provider", "ALLOWED_REPOS", "APP_NAME", + ] + extra_skip_keys = get_settings().config.get("skip_keys", []) + if extra_skip_keys: + skip_keys.extend(extra_skip_keys) + skip_keys_lower = [str(key).lower() for key in skip_keys] + + markdown_text = "" + markdown_text += "\n
      \n
      🛠️ Relevant configurations: \n\n" + markdown_text += ( + "
      These are the relevant [configurations]" + "(https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml)" + " for this tool:\n\n" + ) + markdown_text += "**[config**]\n```yaml\n\n" + for key, value in get_settings().config.items(): + if key.lower() in skip_keys_lower: + continue + markdown_text += f"{key}: {_render_setting_value(value)}\n" + markdown_text += "\n```\n" + markdown_text += f"\n**[{relevant_section}]**\n```yaml\n\n" + for key, value in get_settings().get(relevant_section, {}).items(): + if key.lower() in skip_keys_lower: + continue + markdown_text += f"{key}: {_render_setting_value(value)}\n" + markdown_text += "\n```" + markdown_text += "\n
      \n" + return markdown_text + + +def _format_usd(cost: Decimal) -> str: + """Format cost at two decimals without turning a positive amount into false zero.""" + rounded = cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if cost > 0 and rounded == 0: + return "<$0.01" + return f"${rounded:.2f}" + + +def show_run_details(gfm_supported: bool) -> str: + """Render the opt-in run-details section (model, tokens, time cost, AI calls). + + Falls back to a plain, non-collapsible section when the provider does not + support GitHub-flavored markdown, so the information stays visible. + """ + details = get_run_details() + if details is None or not details.model_used: + return "" + + title = "⚙️ Agent run details" + if len(details.models_used) > 1: + lines = [f"- Models: {', '.join(details.models_used)}{' (includes fallback)' if details.fallback_used else ''}"] + else: + lines = [f"- Model: {details.model_used}{' (fallback)' if details.fallback_used else ''}"] + if details.has_token_usage: + # Drop zero-valued components because providers may omit their usage counts. + counts = [(details.prompt_tokens, "in"), (details.completion_tokens, "out"), + (details.total_tokens, "total")] + reported = [f"{value:,} {label}" for value, label in counts if value] + lines.append(f"- Tokens: {' / '.join(reported)}") + lines.append(f"- Time cost: {details.duration_seconds:.1f}s") + if details.num_ai_calls: + lines.append(f"- AI calls: {details.num_ai_calls}") + if get_settings().get("config.output_run_cost", False) and details.num_ai_calls: + if details.cost_status == "unavailable": + # Report pricing as unavailable without asserting a cause: usage may be + # missing (streaming without a final usage chunk) or uncollected by the handler. + lines.append("- Estimated API cost: unavailable (no calls could be priced)") + else: + partial = "" + if details.cost_status == "partial": + partial = (f" (partial: {details.known_cost_call_count} of " + f"{details.num_ai_calls} successful calls priced)") + lines.append(f"- Estimated API cost: {_format_usd(details.total_cost_usd)} USD{partial}") + if len(details.model_costs_usd) > 1: + for model, cost in details.model_costs_usd.items(): + lines.append(f" - {model}: {_format_usd(cost)} USD") + body = "\n".join(lines) + + if gfm_supported: + return (f"\n
      \n
      {title}\n\n" + f"{body}\n\n
      \n") + return f"\n___\n\n**{title}**\n\n{body}\n" + + +def get_version() -> str: + # First check pyproject.toml if running directly out of the pr-agent repository + if os.path.exists("pyproject.toml"): + if sys.version_info >= (3, 11): + import tomllib + try: + with open("pyproject.toml", "rb") as f: + data = tomllib.load(f) + except (OSError, ValueError) as e: # tomllib raises TOMLDecodeError, or UnicodeDecodeError on non-UTF-8 + get_logger().warning(f"Unable to read pyproject.toml, falling back to package metadata: {e}") + else: + # only trust this file when it is pr-agent's own pyproject.toml, otherwise an + # unrelated project in the current working directory would dictate our version + project = data.get("project", {}) + if project.get("name") == "pr-agent": + if "version" in project: + return project["version"] + get_logger().warning("Version not found in pyproject.toml") + else: + get_logger().warning("Unable to determine local version from pyproject.toml") + + # Otherwise get the installed pip package version + try: + return version('pr-agent') + except PackageNotFoundError: + get_logger().warning("Unable to find package named 'pr-agent'") + return "unknown" diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 77bffd27ee..6535d34c6a 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -4,19 +4,14 @@ import difflib import html import json -import os import re -import sys import textwrap -from datetime import datetime, timezone -from decimal import ROUND_HALF_UP, Decimal +from datetime import datetime from enum import Enum -from importlib.metadata import PackageNotFoundError, version from typing import Any, List, Tuple, TypedDict -from urllib.parse import quote, unquote, urlparse +from urllib.parse import quote, unquote import html2text -import requests import yaml from pydantic import BaseModel @@ -26,7 +21,6 @@ extract_hunk_lines_from_patch, to_hunk_only_patch, ) -from pr_agent.algo.run_details import get_run_details from pr_agent.algo.types import FilePatchInfo from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.log import get_logger @@ -1330,212 +1324,6 @@ def scan_patch_lines(is_match): return position, absolute_position -def github_action_output(output_data: dict, key_name: str): - try: - enable_output = get_settings().get('github_action_config.enable_output', False) - if isinstance(enable_output, str): - enable_output = enable_output.lower().strip() not in ("false", "0", "no", "") - if not enable_output: - return - - key_data = output_data.get(key_name, {}) - with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: - print(f"{key_name}={json.dumps(key_data, indent=None, ensure_ascii=False)}", file=fh) - except Exception as e: - get_logger().error(f"Failed to write to GitHub Action output: {e}") - return - - -def _push_outputs_sink_url(cfg: dict, key: str) -> str: - """Return cfg[key] if it is an absolute https URL with a host, else "" (with a warning). - - Requiring https keeps the review text, which can quote private code, off plaintext - transports. The host is not restricted: self-hosted collectors and Slack-compatible - endpoints (Mattermost, Rocket.Chat) are legitimate targets. - """ - url = cfg.get(key) or '' - if not url: - return '' - parsed = urlparse(url) - if parsed.scheme != 'https' or not parsed.hostname: - # Log the key, never the value: a webhook URL is itself the credential. - get_logger().warning(f"push_outputs: ignoring {key}, expected an absolute https:// URL") - return '' - return url - - -def push_outputs(message_type: str, payload: dict | None = None, markdown: str | None = None) -> None: - """Emit a tool's output to external sinks, without calling any git-provider API. - - Controlled by the [push_outputs] config section (disabled by default). Supported channels: - "stdout" (one JSON line), "file" (append JSONL), "webhook" (POST the generic record), - "slack" (POST {"text": ...} to a Slack Incoming Webhook). Non-fatal: never raises. - """ - try: - cfg = get_settings().get('push_outputs', {}) or {} - enable = cfg.get('enable', False) - if isinstance(enable, str): # env vars arrive as strings; treat "false"/"0"/"no"/"" as off - enable = enable.lower().strip() not in ("false", "0", "no", "") - if not enable: - return - - channels = cfg.get('channels', []) or [] - record = { - "type": message_type, - "timestamp": datetime.now(timezone.utc).isoformat(), - "payload": payload or {}, - } - if markdown is not None: - record["markdown"] = markdown - - if "stdout" in channels: - try: - print(json.dumps(record, ensure_ascii=False)) - except Exception as e: - get_logger().warning(f"push_outputs: stdout failed: {type(e).__name__}") - - if "file" in channels: - try: - file_path = cfg.get('file_path', 'pr-agent-outputs/reviews.jsonl') - folder = os.path.dirname(file_path) - if folder: - os.makedirs(folder, exist_ok=True) - with open(file_path, 'a', encoding='utf-8') as fh: - fh.write(json.dumps(record, ensure_ascii=False) + "\n") - except Exception as e: - get_logger().warning(f"push_outputs: file failed: {type(e).__name__}") - - # Local channels first, network last, so a failed POST can't lose a file write. - # allow_redirects=False: never follow a redirect from a configured sink to another host. - if "webhook" in channels: - try: - webhook_url = _push_outputs_sink_url(cfg, 'webhook_url') - if webhook_url: - response = requests.post(webhook_url, json=record, timeout=5, allow_redirects=False) - if not 200 <= response.status_code < 300: - get_logger().warning(f"push_outputs: webhook failed with status {response.status_code}") - except Exception as e: - get_logger().warning(f"push_outputs: webhook failed: {type(e).__name__}") - - # Slack Incoming Webhooks accept {"text": ...} directly, no relay service needed. - if "slack" in channels: - try: - slack_webhook_url = _push_outputs_sink_url(cfg, 'slack_webhook_url') - if slack_webhook_url: - text = markdown if markdown is not None else json.dumps(payload or {}, ensure_ascii=False) - response = requests.post(slack_webhook_url, json={"text": text}, timeout=5, - allow_redirects=False) - if not 200 <= response.status_code < 300: - get_logger().warning(f"push_outputs: slack failed with status {response.status_code}") - except Exception as e: - get_logger().warning(f"push_outputs: slack failed: {type(e).__name__}") - except Exception as e: - # Log only the exception type: requests errors embed the (secret-bearing) URL in their text. - get_logger().warning(f"push_outputs failed: {type(e).__name__}") - - -def _render_setting_value(value) -> str: - """Render a settings value as YAML, so nested values do not become Python reprs.""" - if isinstance(value, (str, int, float, bool)) or value is None: - return str(value) - try: - return yaml.safe_dump(_to_plain(value), default_flow_style=True).strip() - except Exception: - return str(value) - - -def _to_plain(value): - """Convert Dynaconf boxes to plain dict/list so yaml can represent them.""" - if isinstance(value, dict): - return {str(k): _to_plain(v) for k, v in value.items()} - if isinstance(value, (list, tuple)): - return [_to_plain(v) for v in value] - return value - - -def show_relevant_configurations(relevant_section: str) -> str: - skip_keys = ['ai_disclaimer', 'ai_disclaimer_title', 'ANALYTICS_FOLDER', 'secret_provider', "skip_keys", "app_id", "redirect", - 'trial_prefix_message', 'no_eligible_message', 'identity_provider', 'ALLOWED_REPOS','APP_NAME'] - extra_skip_keys = get_settings().config.get("skip_keys", []) - if extra_skip_keys: - skip_keys.extend(extra_skip_keys) - skip_keys_lower = [str(key).lower() for key in skip_keys] - - markdown_text = "" - markdown_text += "\n
      \n
      🛠️ Relevant configurations: \n\n" - markdown_text +="
      These are the relevant [configurations](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml) for this tool:\n\n" - markdown_text += "**[config**]\n```yaml\n\n" - for key, value in get_settings().config.items(): - if key.lower() in skip_keys_lower: - continue - markdown_text += f"{key}: {_render_setting_value(value)}\n" - markdown_text += "\n```\n" - markdown_text += f"\n**[{relevant_section}]**\n```yaml\n\n" - for key, value in get_settings().get(relevant_section, {}).items(): - if key.lower() in skip_keys_lower: - continue - markdown_text += f"{key}: {_render_setting_value(value)}\n" - markdown_text += "\n```" - markdown_text += "\n
      \n" - return markdown_text - - -def _format_usd(cost: Decimal) -> str: - """Format cost at two decimals without turning a positive amount into false zero.""" - rounded = cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - if cost > 0 and rounded == 0: - return "<$0.01" - return f"${rounded:.2f}" - - -def show_run_details(gfm_supported: bool) -> str: - """Render the opt-in run-details section (model, tokens, time cost, AI calls). - - Falls back to a plain, non-collapsible section when the provider does not - support GitHub-flavored markdown, so the information stays visible. - """ - details = get_run_details() - if details is None or not details.model_used: - return "" - - title = "⚙️ Agent run details" - if len(details.models_used) > 1: - lines = [f"- Models: {', '.join(details.models_used)}{' (includes fallback)' if details.fallback_used else ''}"] - else: - lines = [f"- Model: {details.model_used}{' (fallback)' if details.fallback_used else ''}"] - if details.has_token_usage: - # A counter still at zero after a successful call means the provider never - # reported that component, so drop it instead of claiming it was zero. - counts = [(details.prompt_tokens, "in"), (details.completion_tokens, "out"), - (details.total_tokens, "total")] - reported = [f"{value:,} {label}" for value, label in counts if value] - lines.append(f"- Tokens: {' / '.join(reported)}") - lines.append(f"- Time cost: {details.duration_seconds:.1f}s") - if details.num_ai_calls: - lines.append(f"- AI calls: {details.num_ai_calls}") - if get_settings().get("config.output_run_cost", False) and details.num_ai_calls: - if details.cost_status == "unavailable": - # No causal claim here: pricing can be missing because usage was absent - # (streaming without a final usage chunk) or because the active handler - # never collects costs (openai/langchain handlers). - lines.append("- Estimated API cost: unavailable (no calls could be priced)") - else: - partial = "" - if details.cost_status == "partial": - partial = (f" (partial: {details.known_cost_call_count} of " - f"{details.num_ai_calls} successful calls priced)") - lines.append(f"- Estimated API cost: {_format_usd(details.total_cost_usd)} USD{partial}") - if len(details.model_costs_usd) > 1: - for model, cost in details.model_costs_usd.items(): - lines.append(f" - {model}: {_format_usd(cost)} USD") - body = "\n".join(lines) - - if gfm_supported: - return (f"\n
      \n
      {title}\n\n" - f"{body}\n\n
      \n") - return f"\n___\n\n**{title}**\n\n{body}\n" - - def is_value_no(value): if not value: return True @@ -1642,34 +1430,6 @@ def process_description(description_full: str) -> Tuple[str, List]: return base_description_str, files -def get_version() -> str: - # First check pyproject.toml if running directly out of the pr-agent repository - if os.path.exists("pyproject.toml"): - if sys.version_info >= (3, 11): - import tomllib - try: - with open("pyproject.toml", "rb") as f: - data = tomllib.load(f) - except (OSError, ValueError) as e: # tomllib raises TOMLDecodeError, or UnicodeDecodeError on non-UTF-8 - get_logger().warning(f"Unable to read pyproject.toml, falling back to package metadata: {e}") - else: - # only trust this file when it is pr-agent's own pyproject.toml, otherwise an - # unrelated project in the current working directory would dictate our version - project = data.get("project", {}) - if project.get("name") == "pr-agent": - if "version" in project: - return project["version"] - get_logger().warning("Version not found in pyproject.toml") - else: - get_logger().warning("Unable to determine local version from pyproject.toml") - - # Otherwise get the installed pip package version - try: - return version('pr-agent') - except PackageNotFoundError: - get_logger().warning("Unable to find package named 'pr-agent'") - return "unknown" - def set_file_languages(diff_files) -> List[FilePatchInfo]: try: diff --git a/pr_agent/cli.py b/pr_agent/cli.py index b28524f5dc..67c30a6609 100644 --- a/pr_agent/cli.py +++ b/pr_agent/cli.py @@ -10,7 +10,7 @@ litellm_callbacks_registered, ) from pr_agent.algo.artifacts import inject_artifact_context -from pr_agent.algo.utils import get_version +from pr_agent.algo.run_output import get_version from pr_agent.command_descriptions import COMMAND_DESCRIPTIONS from pr_agent.config_loader import get_settings from pr_agent.log import get_logger, setup_logger diff --git a/pr_agent/git_providers/local_git_provider.py b/pr_agent/git_providers/local_git_provider.py index a84f6d3625..d793ad4313 100644 --- a/pr_agent/git_providers/local_git_provider.py +++ b/pr_agent/git_providers/local_git_provider.py @@ -6,8 +6,8 @@ from pr_agent.algo.comment_identity import format_pr_code_suggestions_header from pr_agent.algo.language_handler import build_language_file_matcher +from pr_agent.algo.run_output import show_run_details from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo -from pr_agent.algo.utils import show_run_details from pr_agent.config_loader import _find_repository_root, get_settings from pr_agent.git_providers.git_provider import GitProvider from pr_agent.log import get_logger diff --git a/pr_agent/git_providers/plain_diff_provider.py b/pr_agent/git_providers/plain_diff_provider.py index bfa415f3de..5d71f8a5e7 100644 --- a/pr_agent/git_providers/plain_diff_provider.py +++ b/pr_agent/git_providers/plain_diff_provider.py @@ -7,8 +7,8 @@ from pr_agent.algo.comment_identity import format_pr_code_suggestions_header from pr_agent.algo.language_handler import build_language_file_matcher +from pr_agent.algo.run_output import show_run_details from pr_agent.algo.types import FilePatchInfo -from pr_agent.algo.utils import show_run_details from pr_agent.config_loader import _find_repository_root, get_settings from pr_agent.git_providers.diff_parsing import parse_unified_diff, reconstruct_base_file, to_hunk_only_patch from pr_agent.git_providers.git_provider import GitProvider diff --git a/pr_agent/mosaico/card.py b/pr_agent/mosaico/card.py index a66b290283..746de61392 100644 --- a/pr_agent/mosaico/card.py +++ b/pr_agent/mosaico/card.py @@ -12,7 +12,7 @@ from a2a.types import AgentCapabilities, AgentCard, AgentExtension, AgentInterface, AgentSkill -from pr_agent.algo.utils import get_version +from pr_agent.algo.run_output import get_version OBSERVABILITY_EXTENSION_URI = "https://mosaico-project.eu/extensions/mosaico-observability" diff --git a/pr_agent/telemetry/config.py b/pr_agent/telemetry/config.py index d320784dda..f5be0f9f73 100644 --- a/pr_agent/telemetry/config.py +++ b/pr_agent/telemetry/config.py @@ -1,4 +1,4 @@ -from pr_agent.algo.utils import get_version +from pr_agent.algo.run_output import get_version from pr_agent.config_loader import get_settings from pr_agent.log import get_logger from pr_agent.telemetry.prometheus_multiproc import ensure_prometheus_multiproc_dir diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 04e2485e12..032422583b 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -33,6 +33,7 @@ from pr_agent.algo.prompt_fragments import render_diff_hunk_format from pr_agent.algo.repo_context import build_repo_context from pr_agent.algo.run_details import init_run_details, record_command_failure, record_model_used +from pr_agent.algo.run_output import push_outputs, show_relevant_configurations, show_run_details from pr_agent.algo.skills_loader import get_skills_context from pr_agent.algo.token_budget import AttemptTokenBudget, clip_tokens from pr_agent.algo.token_handler import TokenHandler @@ -40,10 +41,7 @@ ModelType, get_model, load_yaml, - push_outputs, replace_code_tags, - show_relevant_configurations, - show_run_details, ) from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.git_providers import get_git_provider_with_context diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 38fa56b111..9802cf28d9 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -19,6 +19,7 @@ ) from pr_agent.algo.repo_context import build_repo_context from pr_agent.algo.run_details import init_run_details, record_command_failure +from pr_agent.algo.run_output import push_outputs, show_relevant_configurations, show_run_details from pr_agent.algo.skills_loader import get_skills_context from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler @@ -26,10 +27,7 @@ ModelType, get_user_labels, load_yaml, - push_outputs, set_custom_labels, - show_relevant_configurations, - show_run_details, ) from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider_with_context diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 1e51c44b96..295a69ef94 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -44,18 +44,20 @@ ) from pr_agent.algo.review_merge import merge_review_chunks from pr_agent.algo.run_details import get_run_details, init_run_details, record_command_failure, record_model_used +from pr_agent.algo.run_output import ( + github_action_output, + push_outputs, + show_relevant_configurations, + show_run_details, +) from pr_agent.algo.skills_loader import get_skills_context from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import ( ModelType, convert_to_markdown_v2, - github_action_output, is_value_no, load_yaml, - push_outputs, - show_relevant_configurations, - show_run_details, ) from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider_with_context diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index f9ce2b09a1..8b7f3b980c 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -13,9 +13,10 @@ get_pr_diff, retry_with_fallback_models, ) +from pr_agent.algo.run_output import show_relevant_configurations from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import ModelType, show_relevant_configurations +from pr_agent.algo.utils import ModelType from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language diff --git a/tests/unittest/test_config_dead_keys.py b/tests/unittest/test_config_dead_keys.py index 1e2677f277..6ba2baf978 100644 --- a/tests/unittest/test_config_dead_keys.py +++ b/tests/unittest/test_config_dead_keys.py @@ -123,27 +123,27 @@ "pr_description", "pr_diagram_direction_threshold", ): "description_settings.pr_diagram_direction_threshold in pr_agent/tools/pr_description.py", - # [push_outputs] read as cfg = get_settings().get('push_outputs', {}) or {} + # [push_outputs] read as cfg = get_settings().get("push_outputs", {}) or {} ( "push_outputs", "enable", - ): "cfg.get('enable', False) in pr_agent/algo/utils.py", + ): "cfg.get(\"enable\", False) in pr_agent/algo/run_output.py", ( "push_outputs", "channels", - ): "cfg.get('channels', []) in pr_agent/algo/utils.py", + ): "cfg.get(\"channels\", []) in pr_agent/algo/run_output.py", ( "push_outputs", "file_path", - ): "cfg.get('file_path', 'pr-agent-outputs/reviews.jsonl') in pr_agent/algo/utils.py", + ): "cfg.get(\"file_path\", \"pr-agent-outputs/reviews.jsonl\") in pr_agent/algo/run_output.py", ( "push_outputs", "webhook_url", - ): "_push_outputs_sink_url(cfg, 'webhook_url') in pr_agent/algo/utils.py", + ): "_push_outputs_sink_url(cfg, \"webhook_url\") in pr_agent/algo/run_output.py", ( "push_outputs", "slack_webhook_url", - ): "_push_outputs_sink_url(cfg, 'slack_webhook_url') in pr_agent/algo/utils.py", + ): "_push_outputs_sink_url(cfg, \"slack_webhook_url\") in pr_agent/algo/run_output.py", } diff --git a/tests/unittest/test_config_yaml_rendering.py b/tests/unittest/test_config_yaml_rendering.py index 018b817391..0dcbbe5ce5 100644 --- a/tests/unittest/test_config_yaml_rendering.py +++ b/tests/unittest/test_config_yaml_rendering.py @@ -4,7 +4,7 @@ import pytest import yaml -from pr_agent.algo.utils import show_relevant_configurations +from pr_agent.algo.run_output import show_relevant_configurations from pr_agent.config_loader import get_settings diff --git a/tests/unittest/test_get_version.py b/tests/unittest/test_get_version.py index f3ae2202da..443619562b 100644 --- a/tests/unittest/test_get_version.py +++ b/tests/unittest/test_get_version.py @@ -1,8 +1,8 @@ """get_version() must not trust a pyproject.toml that belongs to a different project.""" import pytest -from pr_agent.algo import utils -from pr_agent.algo.utils import get_version +from pr_agent.algo import run_output +from pr_agent.algo.run_output import get_version INSTALLED_VERSION = "9.44.0-from-package-metadata" @@ -10,7 +10,7 @@ @pytest.fixture def installed_version(monkeypatch): """Make the package-metadata fallback return a recognizable sentinel.""" - monkeypatch.setattr(utils, "version", lambda name: INSTALLED_VERSION) + monkeypatch.setattr(run_output, "version", lambda name: INSTALLED_VERSION) return INSTALLED_VERSION diff --git a/tests/unittest/test_github_action_output.py b/tests/unittest/test_github_action_output.py index ddd0eac906..b28beba514 100644 --- a/tests/unittest/test_github_action_output.py +++ b/tests/unittest/test_github_action_output.py @@ -1,7 +1,8 @@ import json import os -from pr_agent.algo.utils import get_settings, github_action_output +from pr_agent.algo.run_output import github_action_output +from pr_agent.algo.utils import get_settings class TestGitHubOutput: diff --git a/tests/unittest/test_push_outputs.py b/tests/unittest/test_push_outputs.py index bd9d8d9450..882662bed6 100644 --- a/tests/unittest/test_push_outputs.py +++ b/tests/unittest/test_push_outputs.py @@ -3,8 +3,9 @@ import pytest -from pr_agent.algo import utils -from pr_agent.algo.utils import get_settings, push_outputs +from pr_agent.algo import run_output +from pr_agent.algo.run_output import push_outputs +from pr_agent.config_loader import get_settings @pytest.fixture(autouse=True) @@ -69,7 +70,7 @@ def fake_post(url, json=None, timeout=None, **kwargs): captured['json'] = json return SimpleNamespace(status_code=200) - monkeypatch.setattr(utils.requests, 'post', fake_post) + monkeypatch.setattr(run_output.requests, 'post', fake_post) push_outputs("review", payload={"a": 1}, markdown="a markdown review") @@ -111,7 +112,7 @@ def test_errors_are_non_fatal(self, monkeypatch): def boom(*args, **kwargs): raise ConnectionError("no network") - monkeypatch.setattr(utils.requests, 'post', boom) + monkeypatch.setattr(run_output.requests, 'post', boom) # Must not raise. push_outputs("review", payload={"a": 1}, markdown="hi") @@ -130,7 +131,7 @@ def test_non_https_sink_urls_are_ignored(self, monkeypatch, bad_url): get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', bad_url) posts = [] - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -143,7 +144,7 @@ def test_https_sink_url_is_accepted(self, monkeypatch): get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/hook') posts = [] - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -156,8 +157,8 @@ def test_setup_errors_remain_non_fatal_and_secret_safe(self, monkeypatch): def fail_settings(): raise RuntimeError("secret setup marker") - monkeypatch.setattr(utils, 'get_settings', fail_settings) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output, 'get_settings', fail_settings) + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"payload-secret": 1}, markdown="markdown-secret") @@ -181,8 +182,8 @@ def fake_post(url, **kwargs): raise ConnectionError("transport-secret") return SimpleNamespace(status_code=200) - monkeypatch.setattr(utils.requests, 'post', fake_post) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output.requests, 'post', fake_post) + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"payload-secret": 1}, markdown="markdown-secret") @@ -213,8 +214,8 @@ def fake_post(url, **kwargs): return SimpleNamespace(status_code=status_code, text="response-secret") return SimpleNamespace(status_code=204) - monkeypatch.setattr(utils.requests, 'post', fake_post) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output.requests, 'post', fake_post) + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -230,9 +231,9 @@ def test_remote_2xx_responses_are_silent(self, monkeypatch, status_code): get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') warnings = [] - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda *args, **kwargs: SimpleNamespace(status_code=status_code)) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -247,9 +248,9 @@ def test_malformed_webhook_url_does_not_skip_slack(self, monkeypatch): get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) posts = [] warnings = [] - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -272,9 +273,9 @@ def fail_print(*args, **kwargs): raise OSError("stdout-secret") monkeypatch.setattr('builtins.print', fail_print) - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -291,9 +292,9 @@ def test_file_failure_does_not_skip_remote_destinations(self, monkeypatch, tmp_p get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') posts = [] warnings = [] - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -314,8 +315,8 @@ def fake_post(url, **kwargs): raise TimeoutError("slack-timeout-secret") return SimpleNamespace(status_code=200) - monkeypatch.setattr(utils.requests, 'post', fake_post) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output.requests, 'post', fake_post) + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -329,9 +330,9 @@ def test_slack_non_2xx_warns_without_response_content(self, monkeypatch): get_settings().set('PUSH_OUTPUTS.CHANNELS', ['slack']) get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') warnings = [] - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda *args, **kwargs: SimpleNamespace(status_code=429, text="response-secret")) - monkeypatch.setattr(utils, 'get_logger', + monkeypatch.setattr(run_output, 'get_logger', lambda: SimpleNamespace(warning=warnings.append)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -345,7 +346,7 @@ def test_unknown_and_duplicate_channels_do_not_duplicate_delivery(self, monkeypa webhook_url = 'https://example.test/webhook' get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', webhook_url) posts = [] - monkeypatch.setattr(utils.requests, 'post', + monkeypatch.setattr(run_output.requests, 'post', lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) push_outputs("review", payload={"a": 1}, markdown="hi") diff --git a/tests/unittest/test_show_relevant_configurations.py b/tests/unittest/test_show_relevant_configurations.py index 25d3e9320b..7f4004486b 100644 --- a/tests/unittest/test_show_relevant_configurations.py +++ b/tests/unittest/test_show_relevant_configurations.py @@ -2,7 +2,7 @@ import pytest -from pr_agent.algo.utils import show_relevant_configurations +from pr_agent.algo.run_output import show_relevant_configurations from pr_agent.config_loader import get_settings diff --git a/tests/unittest/test_show_run_details.py b/tests/unittest/test_show_run_details.py index a462fd1a84..2f901eecb7 100644 --- a/tests/unittest/test_show_run_details.py +++ b/tests/unittest/test_show_run_details.py @@ -5,7 +5,7 @@ from pr_agent.algo import run_details from pr_agent.algo.run_details import get_run_details, init_run_details, record_ai_call, record_model_used -from pr_agent.algo.utils import show_run_details +from pr_agent.algo.run_output import show_run_details from pr_agent.config_loader import get_settings diff --git a/tests/unittest/test_telemetry_config.py b/tests/unittest/test_telemetry_config.py index 5c9eafd8d2..fa0c91aa23 100644 --- a/tests/unittest/test_telemetry_config.py +++ b/tests/unittest/test_telemetry_config.py @@ -15,7 +15,7 @@ import pytest import pr_agent -from pr_agent.algo.utils import get_version +from pr_agent.algo.run_output import get_version from pr_agent.telemetry import config as config_module from pr_agent.telemetry.config import VALID_EXPORTER_TYPES, _parse_otlp_headers, get_otel_config from pr_agent.telemetry.types import ExporterType From 9b94b0e96132aa6e6cd0d1987fd39a20cdb1f524 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:08:18 +0545 Subject: [PATCH 219/238] fix: point shipped docs links at live targets (#3556) Co-authored-by: Ismael Martinez Ramos Co-authored-by: Claude Opus 5 (1M context) --- pr_agent/algo/utils.py | 2 +- pr_agent/git_providers/github_provider.py | 2 +- pr_agent/servers/help.py | 6 +++--- pr_agent/tools/pr_reviewer.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 6535d34c6a..328d8e4acf 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -738,7 +738,7 @@ def update_settings_from_args(args: List[str]) -> List[str]: Args: args: A list of arguments passed to the function. Example args: ['--pr_code_suggestions.extra_instructions="be funny', - '--pr_code_suggestions.num_code_suggestions=3'] + '--pr_code_suggestions.num_code_suggestions_per_chunk=3'] Returns: None diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 169f51d18a..d1ba531345 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1636,7 +1636,7 @@ def _get_github_client(self): except AttributeError as e: raise ValueError( "GitHub token is required when using user deployment. See: " - "https://github.com/Codium-ai/pr-agent#method-2-run-from-source") from e + "https://docs.pr-agent.ai/installation/locally/#run-from-source") from e self.auth = Auth.Token(token) if self.auth: github_config = get_settings().github diff --git a/pr_agent/servers/help.py b/pr_agent/servers/help.py index 058216f33c..33a199cfc5 100644 --- a/pr_agent/servers/help.py +++ b/pr_agent/servers/help.py @@ -28,7 +28,7 @@ def get_review_usage_guide(): "More feedback can be [added](https://docs.pr-agent.ai/tools/review/#configuration-options) by configuring the tool.\n\n" "The tool can be triggered [automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on any PR.\n") output +="""\ -- When commenting, to edit [configurations](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L23) related to the review tool (`pr_reviewer` section), use the following template: +- When commenting, to edit [configurations](https://docs.pr-agent.ai/usage-guide/configuration_reference/#pr_reviewer-review) related to the review tool (`pr_reviewer` section), use the following template: ``` /review --pr_reviewer.some_config1=... --pr_reviewer.some_config2=... ``` @@ -53,7 +53,7 @@ def get_describe_usage_guide(): output += "The tool can be triggered [automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on a PR.\n" output += """\ -When commenting, to edit [configurations](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L46) related to the describe tool (`pr_description` section), use the following template: +When commenting, to edit [configurations](https://docs.pr-agent.ai/usage-guide/configuration_reference/#pr_description-describe) related to the describe tool (`pr_description` section), use the following template: ``` /describe --pr_description.some_config1=... --pr_description.some_config2=... ``` @@ -174,7 +174,7 @@ def get_improve_usage_guide(): output += f"{COMMAND_DESCRIPTIONS['improve']} " output += "The tool can be triggered [automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) every time a new PR is opened, or can be invoked manually by commenting on a PR.\n" output += """\ -- When commenting, to edit [configurations](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L78) related to the improve tool (`pr_code_suggestions` section), use the following template: +- When commenting, to edit [configurations](https://docs.pr-agent.ai/usage-guide/configuration_reference/#pr_code_suggestions-improve) related to the improve tool (`pr_code_suggestions` section), use the following template: ``` /improve --pr_code_suggestions.some_config1=... --pr_code_suggestions.some_config2=... diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 295a69ef94..3255ff153a 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -1555,4 +1555,4 @@ def auto_approve_logic(self): else: get_logger().info("Auto-approval option is disabled") self.git_provider.publish_comment("Auto-approval option for PR-Agent is disabled. " - "You can enable it via a [configuration file](https://github.com/Codium-ai/pr-agent/blob/main/docs/REVIEW.md#auto-approval-1)") + "You can enable it via a [configuration file](https://docs.pr-agent.ai/usage-guide/configuration_reference/#config)") From 84ed39e173f75b8cd33e08c3985b541b4aca7768 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:08:35 +0545 Subject: [PATCH 220/238] fix(github): send label names to the set-labels endpoint (#3582) --- pr_agent/git_providers/github_provider.py | 9 +-- tests/unittest/test_github_provider_labels.py | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 tests/unittest/test_github_provider_labels.py diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index d1ba531345..787f0a0f6b 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1710,15 +1710,8 @@ def _get_pr_file_content(self, file: FilePatchInfo, sha: str, path: str = None) def publish_labels(self, pr_types): try: - label_color_map = {"Bug fix": "1d76db", "Tests": "e99695", "Bug fix with tests": "c5def5", - "Enhancement": "bfd4f2", "Documentation": "d4c5f9", - "Other": "d1bcf9"} - post_parameters = [] - for p in pr_types: - color = label_color_map.get(p, "d1bcf9") # default to "Other" color - post_parameters.append({"name": p, "color": color}) headers, data = self.pr._requester.requestJsonAndCheck( - "PUT", f"{self.pr.issue_url}/labels", input=post_parameters + "PUT", f"{self.pr.issue_url}/labels", input=pr_types ) except Exception as e: get_logger().warning(f"Failed to publish labels, error: {e}") diff --git a/tests/unittest/test_github_provider_labels.py b/tests/unittest/test_github_provider_labels.py new file mode 100644 index 0000000000..15b4402d9d --- /dev/null +++ b/tests/unittest/test_github_provider_labels.py @@ -0,0 +1,65 @@ +""" +Tests for GitHub provider label publishing payload shape. + +``publish_labels`` PUTs label names to the issue \"Set labels for an issue\" endpoint, +which requires an array of label name strings. These tests pin that payload shape so +an accidental regression back to label objects cannot slip through. +""" + +from types import SimpleNamespace + +from pr_agent.git_providers.github_provider import GithubProvider + + +class _FakeRequester: + """Records REST calls instead of reaching the GitHub API.""" + + def __init__(self): + self.calls = [] + + def requestJsonAndCheck(self, method, url, input=None): + self.calls.append((method, url, input)) + return ({}, [{"name": "Review effort 3/5"}]) + + +def _make_provider(requester): + provider = GithubProvider.__new__(GithubProvider) + provider.pr = SimpleNamespace( + _requester=requester, + issue_url="https://api.github.com/repos/owner/repo/issues/42", + ) + return provider + + +def test_publish_labels_sends_label_names_as_strings(): + """Send an array of label names, not {\"name\", \"color\"} objects. + + The GitHub set-labels endpoint rejects an object payload with a 422 that the + provider downgrades to a warning, which would leave the labels silently unset. + """ + requester = _FakeRequester() + provider = _make_provider(requester) + + provider.publish_labels(["Review effort 3/5", "Enhancement"]) + + assert requester.calls == [ + ( + "PUT", + "https://api.github.com/repos/owner/repo/issues/42/labels", + ["Review effort 3/5", "Enhancement"], + ) + ] + + +def test_publish_labels_keeps_model_label_names_untouched(): + """The exact names produced by the review and generate_labels flows are sent.""" + requester = _FakeRequester() + provider = _make_provider(requester) + + provider.publish_labels(["Bug fix", "Bug fix with tests", "Possible security concern"]) + + assert requester.calls[0][2] == [ + "Bug fix", + "Bug fix with tests", + "Possible security concern", + ] From ecc91bb4f5d5267938d9b6258bdcb94297adfe92 Mon Sep 17 00:00:00 2001 From: Takao <44014204+high-tail@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:23:50 +0900 Subject: [PATCH 221/238] =?UTF-8?q?test(litellm=5Fopenrouter=5Fcontrols):?= =?UTF-8?q?=20fixed=20broken=20ut=20which=20now=20is=20suppo=E2=80=A6=20(#?= =?UTF-8?q?3584)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_litellm_openrouter_controls.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/unittest/test_litellm_openrouter_controls.py b/tests/unittest/test_litellm_openrouter_controls.py index 0b15a53aaa..fa67b66b78 100644 --- a/tests/unittest/test_litellm_openrouter_controls.py +++ b/tests/unittest/test_litellm_openrouter_controls.py @@ -514,15 +514,14 @@ async def test_reasoning_budget_suppresses_global_effort(self, monkeypatch): ) assert kwargs["extra_body"]["reasoning"] == {"max_tokens": 2048} - def test_litellm_requires_openrouter_reasoning_in_extra_body(self): - """Pin the LiteLLM compatibility boundary so upgrades expose when it can be removed.""" - with pytest.raises(litellm.UnsupportedParamsError): - get_optional_params( - model="google/gemini-2.5-pro", - custom_llm_provider="openrouter", - reasoning_effort="low", - ) - + def test_litellm_passes_openrouter_reasoning_through_extra_body(self): + """Pin the extra_body pass-through the OpenRouter reasoning controls depend on. + + Whether LiteLLM also accepts a top-level reasoning_effort is not asserted here: it + follows supports_reasoning in the model cost map, which every LiteLLM process fetches + from GitHub main at import, so it changes with upstream data rather than our pinned + version. + """ params = get_optional_params( model="google/gemini-2.5-pro", custom_llm_provider="openrouter", From b9fed67c652dd74fa8deca64056a9c5b1c801998 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Mon, 21 Sep 2026 03:24:07 -0500 Subject: [PATCH 222/238] fix: preserve quoting in CLI and configured Action commands (#3585) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- docs/docs/installation/locally.md | 8 +++ pr_agent/agent/pr_agent.py | 24 +++++---- pr_agent/cli.py | 6 +-- pr_agent/servers/github_action_runner.py | 20 ++++++-- .../unittest/test_cli_command_tokenization.py | 45 ++++++++++++++++ .../test_github_action_runner_core.py | 51 ++++++++++++++----- 6 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 tests/unittest/test_cli_command_tokenization.py diff --git a/docs/docs/installation/locally.md b/docs/docs/installation/locally.md index c4fb9e6f2f..aa0f6ed18b 100644 --- a/docs/docs/installation/locally.md +++ b/docs/docs/installation/locally.md @@ -131,6 +131,14 @@ if __name__ == '__main__': With `config.propagate_tool_errors` enabled, forwarding the return value through `SystemExit` makes this script exit with status 1 after a propagated tool error. The default remains status 0. +The Python helper accepts quoted arguments, for example +`cli.run_command(pr_url, "/review --pr_reviewer.extra_instructions='be concise please'")`. +It uses the same quoting rules as configured automation commands: explicitly +quoted setting values stay strings, and unquoted values retain their normal +types. For questions containing apostrophes, use double quotes around the +question, such as `command = '/ask "What\'s changed?"'`. +This does not change how interactive PR comments are parsed. + ## Run from source 1. Clone this repository: diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index 9c398980fd..388c5c5233 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -209,15 +209,8 @@ def flush_token(): return tokens -def prepare_command(command: str) -> list[str]: - """Apply command-line settings while preserving quoted argument boundaries. - - Webhook adapters use this before handing configured commands to ``PRAgent``. Parsing - with ``str.split(" ")`` breaks values such as ``--section.key=\"words with spaces\"``; - the tokenizer keeps the value as one argument and preserves explicit quoting for YAML. - Returning the token list avoids serializing it back to a string, which would otherwise - be re-parsed by ``PRAgent`` and could alter quoted arguments. - """ +def parse_command(command: str) -> list[str]: + """Normalize configured command strings to argv without applying settings.""" tokens = _split_command(command) if not tokens: return [] @@ -229,6 +222,19 @@ def prepare_command(command: str) -> list[str]: key, value = argument.split("=", 1) argument = f"{key}={json.dumps(value, ensure_ascii=False)}" args.append(argument) + return [action] + args + + +def prepare_command(command: str) -> list[str]: + """Apply configured command settings while retaining argument boundaries. + + Return argv so ``PRAgent`` does not parse the command again. Quoted setting + values retain their string type when passed to the settings loader. + """ + command_args = parse_command(command) + if not command_args: + return [] + action, *args = command_args kept, rejected = [], [] for argument in args: # Validate the key only. The value is free text - a review instruction may legitimately diff --git a/pr_agent/cli.py b/pr_agent/cli.py index 67c30a6609..17cecb47bc 100644 --- a/pr_agent/cli.py +++ b/pr_agent/cli.py @@ -3,7 +3,7 @@ import os import sys -from pr_agent.agent.pr_agent import PRAgent, commands +from pr_agent.agent.pr_agent import PRAgent, commands, parse_command from pr_agent.algo.ai_handlers.litellm_helpers import ( DEFAULT_CALLBACK_TIMEOUT_SECONDS, drain_litellm_callbacks, @@ -138,8 +138,8 @@ def set_parser(): def run_command(pr_url, command): # Preparing the command - run_command_str = f"--pr_url={pr_url} {command.lstrip('/')}" - args = set_parser().parse_args(run_command_str.split()) + command_args = parse_command(command.lstrip('/')) + args = set_parser().parse_args([f"--pr_url={pr_url}", *command_args]) # Run the command. Feedback will appear in GitHub PR comments return run(args=args) diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index cf7bd71137..0eb3c855f5 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -7,7 +7,7 @@ import dynaconf -from pr_agent.agent.pr_agent import PRAgent, publish_incomplete_github_files_comment +from pr_agent.agent.pr_agent import PRAgent, parse_command, publish_incomplete_github_files_comment from pr_agent.algo.ai_handlers.litellm_helpers import ( DEFAULT_CALLBACK_TIMEOUT_SECONDS, drain_litellm_callbacks, @@ -76,6 +76,20 @@ async def _handle_request(url, body, notify=None): status.failed = True +async def _handle_configured_command(url, command): + try: + command_args = parse_command(command) if isinstance(command, str) else command + if not command_args: + raise ValueError("Empty configured command") + except ValueError: + get_logger().error("Failed to parse a configured command; skipping it.") + status = _action_status.get() + if status is not None: + status.failed = True + return + await _handle_request(url, command_args) + + async def _run_auto_tool(tool_class, pr_url): """Run a direct auto tool while preserving GitHub Action failure semantics.""" try: @@ -153,7 +167,7 @@ async def _run_review_commands(event_payload): get_settings().pr_description.final_update_message = False get_logger().info(f"Running review commands: {review_commands}") for command in review_commands: - await _handle_request(pr_url, command) + await _handle_configured_command(pr_url, command) async def run_action(): @@ -290,7 +304,7 @@ async def run_action(): get_settings().pr_description.final_update_message = False get_logger().info(f"Running push commands: {push_commands}") for command in push_commands: - await _handle_request(pr_url, command) + await _handle_configured_command(pr_url, command) return if action in pr_actions: pr_url = event_payload.get("pull_request", {}).get("url") diff --git a/tests/unittest/test_cli_command_tokenization.py b/tests/unittest/test_cli_command_tokenization.py new file mode 100644 index 0000000000..aeee5c9504 --- /dev/null +++ b/tests/unittest/test_cli_command_tokenization.py @@ -0,0 +1,45 @@ +from unittest.mock import Mock + +import pytest + +from pr_agent import cli + + +@pytest.mark.parametrize( + ("command", "expected_command", "expected_args"), + [ + ( + "/review --pr_reviewer.extra_instructions='be concise please'", + "review", + ['--pr_reviewer.extra_instructions="be concise please"'], + ), + ( + '/review --pr_reviewer.extra_instructions="true"', + "review", + ['--pr_reviewer.extra_instructions="true"'], + ), + ('/ask "What changed here?"', "ask", ["What changed here?"]), + ], +) +def test_run_command_preserves_quoted_arguments(monkeypatch, command, expected_command, expected_args): + run = Mock(return_value=0) + monkeypatch.setattr(cli, "run", run) + pr_url = "https://example.com/org/repo/pull/1?label=needs%20review&sort=asc" + + assert cli.run_command(pr_url, command) == 0 + + run.assert_called_once() + args = run.call_args.kwargs["args"] + assert args.pr_url == pr_url + assert args.command == expected_command + assert args.rest == expected_args + + +def test_run_command_rejects_unclosed_quote_before_dispatch(monkeypatch): + run = Mock() + monkeypatch.setattr(cli, "run", run) + + with pytest.raises(ValueError, match="No closing quotation"): + cli.run_command("https://example.com/org/repo/pull/1", '/ask "unfinished') + + run.assert_not_called() diff --git a/tests/unittest/test_github_action_runner_core.py b/tests/unittest/test_github_action_runner_core.py index 761179af31..1c2e944749 100644 --- a/tests/unittest/test_github_action_runner_core.py +++ b/tests/unittest/test_github_action_runner_core.py @@ -333,7 +333,9 @@ def fake_get_setting_or_env(key, default=None): "GITHUB_ACTION_CONFIG.ENABLE_OUTPUT": True, "GITHUB_ACTION_CONFIG.REVIEW_STATES": '["changes_requested"]', "GITHUB_ACTION_CONFIG.REVIEW_AUTHOR_TYPES": '["User"]', - "GITHUB_ACTION_CONFIG.REVIEW_COMMANDS": '["/review"]', + "GITHUB_ACTION_CONFIG.REVIEW_COMMANDS": json.dumps([ + "/review --pr_reviewer.extra_instructions='be concise please'" + ]), "GITHUB_ACTION_CONFIG.FEEDBACK_ON_DRAFT_PR": False, } return values.get(key, default) @@ -342,7 +344,10 @@ def fake_get_setting_or_env(key, default=None): await github_action_runner.run_action() - assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/review")] + assert handled == [( + "https://api.github.com/repos/org/repo/pulls/1", + ["/review", '--pr_reviewer.extra_instructions="be concise please"'], + )] @pytest.mark.asyncio @@ -445,7 +450,7 @@ async def test_action_review_config_falls_back_to_app_and_overrides_it( app_review_states=["changes_requested"], app_review_commands=["/app-review"], ) - assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/app-review")] + assert handled == [("https://api.github.com/repos/org/repo/pulls/1", ["/app-review"])] handled = await _run_review_action_with_settings( monkeypatch, @@ -457,7 +462,7 @@ async def test_action_review_config_falls_back_to_app_and_overrides_it( "review_commands": ["/action-review"], }, ) - assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/action-review")] + assert handled == [("https://api.github.com/repos/org/repo/pulls/1", ["/action-review"])] @pytest.mark.asyncio @@ -513,7 +518,9 @@ async def handle_request(self, url, body, notify=None): @pytest.mark.asyncio async def test_synchronize_event_triggers_push_commands(monkeypatch, tmp_path, restore_github_settings): handled = [] - _patch_synchronize_deps(monkeypatch, handled, ["/describe", "/improve"]) + _patch_synchronize_deps(monkeypatch, handled, [ + '/describe --pr_description.extra_instructions="true"', ["/improve", "literal 'text'"] + ]) monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_synchronize_event(tmp_path))) monkeypatch.setenv("GITHUB_TOKEN", "token") @@ -521,10 +528,28 @@ async def test_synchronize_event_triggers_push_commands(monkeypatch, tmp_path, r await github_action_runner.run_action() assert handled == [ - ("https://api.github.com/repos/org/repo/pulls/1", "/describe"), - ("https://api.github.com/repos/org/repo/pulls/1", "/improve"), + ("https://api.github.com/repos/org/repo/pulls/1", ["/describe", '--pr_description.extra_instructions="true"']), + ("https://api.github.com/repos/org/repo/pulls/1", ["/improve", "literal 'text'"]), ] + +@pytest.mark.parametrize("command", ['/review --pr_reviewer.extra_instructions="unfinished', " "]) +def test_invalid_configured_command_fails_action_but_continues( + monkeypatch, tmp_path, restore_github_settings, command +): + handled = [] + _patch_synchronize_deps(monkeypatch, handled, [command, "/review"]) + monkeypatch.setattr(github_action_runner, "litellm_callbacks_registered", lambda: False) + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_synchronize_event(tmp_path))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + with pytest.raises(SystemExit) as error: + github_action_runner.main() + + assert error.value.code == 1 + assert handled == [("https://api.github.com/repos/org/repo/pulls/1", ["/review"])] + def test_action_exits_nonzero_when_command_fails_and_continues( monkeypatch, tmp_path, restore_github_settings ): @@ -534,7 +559,7 @@ def test_action_exits_nonzero_when_command_fails_and_continues( class FakeAgent: async def handle_request(self, url, body, notify=None): handled.append((url, body)) - return body != "/review" + return body != ["/review"] monkeypatch.setattr(github_action_runner, "PRAgent", FakeAgent) monkeypatch.setattr(github_action_runner, "litellm_callbacks_registered", lambda: False) @@ -547,8 +572,8 @@ async def handle_request(self, url, body, notify=None): assert exc_info.value.code == 1 assert handled == [ - ("https://api.github.com/repos/org/repo/pulls/1", "/review"), - ("https://api.github.com/repos/org/repo/pulls/1", "/improve"), + ("https://api.github.com/repos/org/repo/pulls/1", ["/review"]), + ("https://api.github.com/repos/org/repo/pulls/1", ["/improve"]), ] @pytest.mark.asyncio @@ -591,8 +616,8 @@ async def test_synchronize_event_triggers_push_commands_on_pull_request_target( await github_action_runner.run_action() assert handled == [ - ("https://api.github.com/repos/org/repo/pulls/1", "/describe"), - ("https://api.github.com/repos/org/repo/pulls/1", "/improve"), + ("https://api.github.com/repos/org/repo/pulls/1", ["/describe"]), + ("https://api.github.com/repos/org/repo/pulls/1", ["/improve"]), ] @@ -643,7 +668,7 @@ async def test_synchronize_uses_github_action_config_push_commands(monkeypatch, await github_action_runner.run_action() assert handled == [ - ("https://api.github.com/repos/org/repo/pulls/1", "/describe"), + ("https://api.github.com/repos/org/repo/pulls/1", ["/describe"]), ] From 1d01f24f455bb879c1d9c557ad7de3d72dcc7975 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:06:51 +0545 Subject: [PATCH 223/238] fix: check pinecone upsert response (#3561) --- pr_agent/tools/pr_similar_issue.py | 84 +++++++- tests/unittest/test_similar_issue_pinecone.py | 193 ++++++++++++++++++ 2 files changed, 272 insertions(+), 5 deletions(-) diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index 95900bca8c..f3442b0ed4 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -14,6 +14,8 @@ from pr_agent.log import get_logger MODEL = "text-embedding-ada-002" +PINECONE_UPSERT_READY_TIMEOUT_SECONDS = 30 +PINECONE_UPSERT_READY_POLL_SECONDS = 0.5 _EMBEDDING_CLIENTS = {} @@ -89,6 +91,76 @@ def _pinecone_namespace(repo_full_name: str) -> str: return f"repo-{hashlib.sha256(repo_full_name.lower().encode()).hexdigest()}" +def _raise_on_pinecone_upsert_errors(response): + """Raise when Pinecone reports asynchronous batch failures in the upsert response.""" + response_dict = {} + if isinstance(response, dict): + response_dict = response + elif hasattr(response, "to_dict"): + response_dict = response.to_dict() + + failed_item_count = getattr(response, "failed_item_count", response_dict.get("failed_item_count")) + has_errors = getattr(response, "has_errors", response_dict.get("has_errors", False)) + if not has_errors and not failed_item_count: + return + + errors = getattr(response, "errors", response_dict.get("errors")) or [] + get_logger().error( + "Pinecone upsert failed", + artifact={ + "failed_item_count": failed_item_count, + "errors": [_get_value(error, "error_message") for error in errors], + }, + ) + raise RuntimeError(f"Pinecone upsert failed for {failed_item_count} vectors") + + +def _get_value(source, key, default=None): + if source is None: + return default + if isinstance(source, dict): + return source.get(key, default) + return getattr(source, key, default) + + +def _pinecone_response_info(response): + response_info = _get_value(response, "response_info") + if response_info is None and hasattr(response, "to_dict"): + response_info = response.to_dict().get("response_info") + return response_info + + +def _pinecone_lsn_committed(response): + response_info = _pinecone_response_info(response) + return _get_value(response_info, "lsn_committed") + + +def _pinecone_response_is_reconciled(response, target_lsn): + response_info = _pinecone_response_info(response) + if response_info is None: + return False + if hasattr(response_info, "is_reconciled"): + return response_info.is_reconciled(target_lsn) + lsn_reconciled = _get_value(response_info, "lsn_reconciled") + return lsn_reconciled is not None and lsn_reconciled >= target_lsn + + +def _wait_for_pinecone_upsert_readiness(pinecone_index, upsert_response, namespace, vector_id): + target_lsn = _pinecone_lsn_committed(upsert_response) + if target_lsn is None: + get_logger().warning("Pinecone upsert response did not include an LSN; skipping readiness wait") + return + + deadline = time.monotonic() + PINECONE_UPSERT_READY_TIMEOUT_SECONDS + while True: + if time.monotonic() >= deadline: + raise TimeoutError(f"Pinecone upsert was not query-ready after {PINECONE_UPSERT_READY_TIMEOUT_SECONDS}s") + fetch_response = pinecone_index.fetch(ids=[vector_id], namespace=namespace) + if _pinecone_response_is_reconciled(fetch_response, target_lsn): + return + time.sleep(PINECONE_UPSERT_READY_POLL_SECONDS) + + def _provider_supports_issue_indexing() -> bool: """Whether the configured provider can back `/similar_issue`. @@ -563,11 +635,13 @@ def _update_index_with_issues(self, issues_list, repo_name_for_index, pinecone_n timeout=120) get_logger().info('Upserting index...') self.pinecone_index = self.pc.Index(name=self.index_name) - self.pinecone_index.upsert(vectors=vectors, - namespace=pinecone_namespace, - batch_size=100, - max_concurrency=10) - time.sleep(5) # wait for pinecone to finalize upserting before querying + upsert_response = self.pinecone_index.upsert(vectors=vectors, + namespace=pinecone_namespace, + batch_size=100, + max_concurrency=10) + _raise_on_pinecone_upsert_errors(upsert_response) + _wait_for_pinecone_upsert_readiness(self.pinecone_index, upsert_response, + namespace=pinecone_namespace, vector_id=vectors[0][0]) get_logger().info('Done') def _table_exists_in_db(self, index_name) -> bool: diff --git a/tests/unittest/test_similar_issue_pinecone.py b/tests/unittest/test_similar_issue_pinecone.py index 57e38524f7..0998152317 100644 --- a/tests/unittest/test_similar_issue_pinecone.py +++ b/tests/unittest/test_similar_issue_pinecone.py @@ -3,6 +3,8 @@ from pathlib import Path from types import SimpleNamespace +import pytest + import pr_agent.tools.pr_similar_issue as psi @@ -224,6 +226,197 @@ def upsert(self, **kwargs): assert created == [] +def test_pinecone_upsert_response_errors_raise_before_sleep(monkeypatch): + logged = [] + + class FakeIndex: + def upsert(self, **kwargs): + return SimpleNamespace(has_errors=True, failed_item_count=100) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + monkeypatch.setattr(psi.time, "sleep", lambda seconds: pytest.fail("should not sleep after upsert errors")) + monkeypatch.setattr( + psi, + "get_logger", + lambda: SimpleNamespace( + info=lambda *args, **kwargs: None, + error=lambda *args, **kwargs: logged.append(kwargs.get("artifact")), + ), + ) + + with pytest.raises(RuntimeError, match="100"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + assert logged == [{"failed_item_count": 100, "errors": []}] + + +def test_pinecone_upsert_response_dict_errors_raise(monkeypatch): + class FakeIndex: + def upsert(self, **kwargs): + return {"has_errors": True, "failed_item_count": 5} + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + + with pytest.raises(RuntimeError, match="5"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + +def test_pinecone_upsert_failed_item_count_raises_without_has_errors(monkeypatch): + class FakeIndex: + def upsert(self, **kwargs): + return {"failed_item_count": 3} + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + + with pytest.raises(RuntimeError, match="3"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + +def test_pinecone_upsert_logs_batch_error_messages(monkeypatch): + logged = [] + + class FakeIndex: + def upsert(self, **kwargs): + return SimpleNamespace( + has_errors=True, + failed_item_count=2, + errors=[ + SimpleNamespace(error_message="namespace does not exist"), + {"error_message": "duplicate id"}, + ], + ) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + monkeypatch.setattr( + psi, + "get_logger", + lambda: SimpleNamespace( + info=lambda *args, **kwargs: None, + error=lambda *args, **kwargs: logged.append(kwargs.get("artifact")), + ), + ) + + with pytest.raises(RuntimeError, match="2"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + assert logged == [ + { + "failed_item_count": 2, + "errors": ["namespace does not exist", "duplicate id"], + }, + ] + + +def test_pinecone_upsert_waits_until_lsn_is_reconciled(monkeypatch): + fetched = [] + sleeps = [] + + class FakeIndex: + def upsert(self, **kwargs): + return SimpleNamespace( + has_errors=False, + response_info=SimpleNamespace(lsn_committed=10), + ) + + def fetch(self, **kwargs): + fetched.append(kwargs) + lsn_reconciled = 5 if len(fetched) == 1 else 10 + return SimpleNamespace(response_info=SimpleNamespace( + is_reconciled=lambda target: lsn_reconciled >= target + )) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + monkeypatch.setattr(psi.time, "sleep", lambda seconds: sleeps.append(seconds)) + + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + assert fetched == [ + {"ids": ["example_issue_example-repo"], "namespace": "ns"}, + {"ids": ["example_issue_example-repo"], "namespace": "ns"}, + ] + assert sleeps == [psi.PINECONE_UPSERT_READY_POLL_SECONDS] + + +def test_pinecone_upsert_wait_supports_dict_response_info(): + class FakeIndex: + def __init__(self): + self.fetch_calls = 0 + + def fetch(self, **kwargs): + self.fetch_calls += 1 + return {"response_info": {"lsn_reconciled": 20}} + + fake_index = FakeIndex() + + psi._wait_for_pinecone_upsert_readiness( + fake_index, + {"response_info": {"lsn_committed": 20}}, + namespace="", + vector_id="example_issue_example-repo", + ) + + assert fake_index.fetch_calls == 1 + + +def test_pinecone_upsert_wait_checks_deadline_before_fetch(monkeypatch): + state = {"t": 0.0} + fetched = [] + + class FakeIndex: + def fetch(self, **kwargs): + fetched.append(kwargs) + return {"response_info": {"lsn_reconciled": 1}} + + monkeypatch.setattr(psi.time, "monotonic", lambda: state["t"]) + monkeypatch.setattr( + psi.time, + "sleep", + lambda seconds: state.__setitem__( + "t", state["t"] + psi.PINECONE_UPSERT_READY_TIMEOUT_SECONDS + 1 + ), + ) + + with pytest.raises(TimeoutError, match="not query-ready"): + psi._wait_for_pinecone_upsert_readiness( + FakeIndex(), + {"response_info": {"lsn_committed": 10}}, + namespace="ns", + vector_id="example_issue_example-repo", + ) + + assert len(fetched) == 1 + + def test_pinecone_create_index_path_builds_new_index_then_upserts(monkeypatch): created = [] upserted = [] From e50017ea83c1b44b56439636739d0e17ac4b1f07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:38:16 +0000 Subject: [PATCH 224/238] chore(release): bump version to 0.46.0 [skip ci] --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 229b5264bb..ec64d90c1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pr-agent" -version = "0.45.0" +version = "0.46.0" authors = [ { name = "QodoAI", email = "ofir.f@qodo.ai" }, diff --git a/uv.lock b/uv.lock index 16bb47511f..8f8b8a9662 100644 --- a/uv.lock +++ b/uv.lock @@ -2463,7 +2463,7 @@ wheels = [ [[package]] name = "pr-agent" -version = "0.45.0" +version = "0.46.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk", extra = ["http-server"] }, From c782819f7556075fa8c89c9fd6d116e0df0410ad Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:53:30 +0800 Subject: [PATCH 225/238] chore(ci): align native test checkout action with v7.0.1 (#3593) --- .github/workflows/build-and-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index a8be9cda53..293859273b 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -110,7 +110,7 @@ jobs: steps: - id: checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Same uv release the Dockerfile pulls from ghcr.io/astral-sh/uv, via pipx # so there is no additional action to pin. uv provisions the interpreter From 53f599a7eb33e4a7d2299c091c233831d24bfc08 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:54:05 +0800 Subject: [PATCH 226/238] perf(diff): use set membership when packing remaining files (#3591) --- pr_agent/algo/pr_processing.py | 3 +- tests/unittest/test_diff_remaining_files.py | 53 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/unittest/test_diff_remaining_files.py diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index addbd243d3..a269d0c9c3 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -587,8 +587,9 @@ def generate_full_patch(convert_hunks_to_line_numbers, file_dict, soft_token_bud remaining_files_list_new = [] files_in_patch_list = [] separator_tokens = None + remaining_files = set(remaining_files_list_prev) for filename, data in file_dict.items(): - if filename not in remaining_files_list_prev: + if filename not in remaining_files: continue patch = data['patch'] diff --git a/tests/unittest/test_diff_remaining_files.py b/tests/unittest/test_diff_remaining_files.py new file mode 100644 index 0000000000..5d50495da1 --- /dev/null +++ b/tests/unittest/test_diff_remaining_files.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace + +import pytest + +from pr_agent.algo.pr_processing import generate_full_patch + + +@pytest.mark.parametrize("line_numbers", [False, True]) +@pytest.mark.parametrize( + ("remaining_files", "expected_files"), + [ + ([], []), + (["missing.py"], []), + (["c.py", "a.py", "c.py", "empty.py", "missing.py"], ["a.py", "c.py"]), + ], +) +def test_remaining_files_preserve_file_order_and_input(line_numbers, remaining_files, expected_files): + files = {name: {"patch": "+x"} for name in ("a.py", "b.py", "c.py")} + files["empty.py"] = {"patch": ""} + original_remaining = remaining_files.copy() + counter = SimpleNamespace(prompt_tokens=7, count_tokens=len) + + total, patches, remaining, included = generate_full_patch( + line_numbers, files, 10000, remaining_files, counter, hard_token_budget=10000 + ) + + expected_patches = [ + "\n\n+x" if line_numbers else f"\n\n## File: '{name}'\n\n+x\n" + for name in expected_files + ] + assert (total, patches, remaining, included) == ( + 7 + len("\n".join(expected_patches)), expected_patches, [], expected_files + ) + assert remaining_files == original_remaining + + +@pytest.mark.parametrize("line_numbers", [False, True]) +def test_remaining_files_can_be_packed_in_successive_rounds(line_numbers): + files = {name: {"patch": "+x"} for name in ("a.py", "b.py", "c.py")} + remaining = ["c.py", "a.py", "b.py", "a.py"] + counter = SimpleNamespace(prompt_tokens=7, count_tokens=len) + budget = len("\n\n+x" if line_numbers else "\n\n## File: 'a.py'\n\n+x\n") + + for index, name in enumerate(files): + previous_remaining = remaining.copy() + total, patches, next_remaining, included = generate_full_patch( + line_numbers, files, budget, remaining, counter, hard_token_budget=budget + ) + expected_patch = "\n\n+x" if line_numbers else f"\n\n## File: '{name}'\n\n+x\n" + assert (total, patches, included) == (7 + budget, [expected_patch], [name]) + assert next_remaining == list(files)[index + 1:] + assert remaining == previous_remaining + remaining = next_remaining From e3bf5c856176945bfd1e1c31c329a90468986610 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:54:46 +0800 Subject: [PATCH 227/238] perf(context): optimize markdown fence selection (#3592) --- pr_agent/algo/repo_context.py | 11 +++-- tests/unittest/test_repo_context_fences.py | 51 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/unittest/test_repo_context_fences.py diff --git a/pr_agent/algo/repo_context.py b/pr_agent/algo/repo_context.py index 12efb8ec76..2d47e71549 100644 --- a/pr_agent/algo/repo_context.py +++ b/pr_agent/algo/repo_context.py @@ -1,3 +1,4 @@ +import re import time from collections import OrderedDict from html import escape @@ -60,10 +61,12 @@ def __setitem__(self, key, value): def _get_markdown_fence(content: str) -> str: - fence = MARKDOWN_FENCE - while fence in content: - fence += "`" - return fence + if MARKDOWN_FENCE not in content: + return MARKDOWN_FENCE + min_fence_length = len(MARKDOWN_FENCE) + pattern = rf"`{{{min_fence_length},}}" + longest_run = max(match.end() - match.start() for match in re.finditer(pattern, content)) + return "`" * (longest_run + 1) def _get_repo_context_cache_key( diff --git a/tests/unittest/test_repo_context_fences.py b/tests/unittest/test_repo_context_fences.py new file mode 100644 index 0000000000..e6e640bfc9 --- /dev/null +++ b/tests/unittest/test_repo_context_fences.py @@ -0,0 +1,51 @@ +import pytest + +from pr_agent.algo.repo_context import ( + INSTRUCTION_FILES_INTRO, + TRUNCATION_MARKER, + _get_markdown_fence, + render_instruction_files, + render_instruction_files_with_line_budget, +) + + +@pytest.mark.parametrize( + ("content", "fence_length"), + [ + ("", 5), + ("Use `type hints`.\n```python\nx = 1\n```", 5), + ("````\n````", 5), + ("`````", 6), + ("``````\n`````", 7), + ("中文```````說明`````", 8), + (" ".join(["`"] * 2000) + "\n`````", 6), + ("prefix\n" + "`" * 50000 + "\nsuffix", 50001), + ], +) +def test_markdown_fence_exceeds_longest_backtick_run(content, fence_length): + assert _get_markdown_fence(content) == "`" * fence_length + + +def test_markdown_fence_scan_follows_configured_minimum(monkeypatch): + monkeypatch.setattr("pr_agent.algo.repo_context.MARKDOWN_FENCE", "````") + assert _get_markdown_fence("````") == "`````" + + +@pytest.mark.parametrize("max_lines", [None, 10]) +def test_instruction_renderer_preserves_fences_when_truncating(max_lines): + content = "Before\n`````````\nAfter\n`````" + files = {"AGENTS.md": content} + if max_lines is None: + rendered = render_instruction_files(files) + expected_content = content + else: + rendered = render_instruction_files_with_line_budget(files, max_lines) + expected_content = f"Before\n{TRUNCATION_MARKER}" + assert len(rendered.splitlines()) == max_lines + + assert rendered == ( + f"{INSTRUCTION_FILES_INTRO}\n\n" + '\n' + f"``````````markdown\n{expected_content}\n``````````\n" + "\n\n" + ) From 16e5ea283acb0a53e431963be13d2dc408e2d0fc Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:04:29 +0800 Subject: [PATCH 228/238] fix(tokens): count special-token literals as ordinary text (#3573) --- pr_agent/algo/skills_loader.py | 2 +- pr_agent/algo/token_budget.py | 2 +- pr_agent/algo/token_handler.py | 4 +- tests/unittest/test_special_token_literals.py | 78 +++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 tests/unittest/test_special_token_literals.py diff --git a/pr_agent/algo/skills_loader.py b/pr_agent/algo/skills_loader.py index ddc9ed24b7..7b0d832b76 100644 --- a/pr_agent/algo/skills_loader.py +++ b/pr_agent/algo/skills_loader.py @@ -95,7 +95,7 @@ def _expanded_skill_paths(paths: List[str]) -> Tuple[str, ...]: def _count_tokens(text: str) -> int: - return len(TokenEncoder.get_token_encoder().encode(text)) + return len(TokenEncoder.get_token_encoder().encode(text, disallowed_special=())) def _gather_resources(skill_md_path: str) -> Tuple[SkillResource, ...]: diff --git a/pr_agent/algo/token_budget.py b/pr_agent/algo/token_budget.py index 08d5d65690..52438bc415 100644 --- a/pr_agent/algo/token_budget.py +++ b/pr_agent/algo/token_budget.py @@ -208,7 +208,7 @@ def clip_tokens(text: str, max_tokens: int, add_three_dots=True, num_input_token try: if num_input_tokens is None: encoder = TokenEncoder.get_token_encoder() - num_input_tokens = len(encoder.encode(text)) + num_input_tokens = len(encoder.encode(text, disallowed_special=())) if num_input_tokens <= max_tokens: return text if max_tokens < 0: diff --git a/pr_agent/algo/token_handler.py b/pr_agent/algo/token_handler.py index cf54a96666..78cedc0234 100644 --- a/pr_agent/algo/token_handler.py +++ b/pr_agent/algo/token_handler.py @@ -114,8 +114,8 @@ def _get_system_user_tokens(self, pr, encoder, vars: dict, system, user): environment = Environment(undefined=StrictUndefined) system_prompt = environment.from_string(system).render(vars) user_prompt = environment.from_string(user).render(vars) - system_prompt_tokens = len(encoder.encode(system_prompt)) - user_prompt_tokens = len(encoder.encode(user_prompt)) + system_prompt_tokens = len(encoder.encode(system_prompt, disallowed_special=())) + user_prompt_tokens = len(encoder.encode(user_prompt, disallowed_special=())) return system_prompt_tokens + user_prompt_tokens except Exception as e: get_logger().error(f"Error in _get_system_user_tokens: {e}") diff --git a/tests/unittest/test_special_token_literals.py b/tests/unittest/test_special_token_literals.py new file mode 100644 index 0000000000..156b3bfb93 --- /dev/null +++ b/tests/unittest/test_special_token_literals.py @@ -0,0 +1,78 @@ +from unittest.mock import Mock + +import pytest + +from pr_agent.algo.skills_loader import Skill, format_skills_context +from pr_agent.algo.token_budget import clip_tokens +from pr_agent.algo.token_handler import TokenEncoder, TokenHandler + + +@pytest.fixture +def encoder(monkeypatch): + def encode(text, *, disallowed_special="all"): + if disallowed_special != () and "<|endoftext|>" in text: + raise ValueError("Encountered text corresponding to disallowed special token") + return list(text) + + encoder = Mock() + encoder.encode.side_effect = encode + monkeypatch.setattr(TokenEncoder, "get_token_encoder", lambda model=None: encoder) + return encoder + + +@pytest.mark.parametrize("max_tokens", [-1, 0, 60, 10_000]) +@pytest.mark.parametrize("delete_last_line", [False, True]) +def test_special_token_literals_do_not_bypass_clipping(encoder, max_tokens, delete_last_line): + text = "line containing <|endoftext|>\n" * 20 + expected = clip_tokens( + text, max_tokens, num_input_tokens=len(text), delete_last_line=delete_last_line, + ) + + assert clip_tokens(text, max_tokens, delete_last_line=delete_last_line) == expected + encoder.encode.assert_called_once_with(text, disallowed_special=()) + + +def test_special_token_clipping_preserves_no_marker_option(encoder): + text = "<|endoftext|> " * 100 + + assert clip_tokens(text, 20, add_three_dots=False) == text[:18] + encoder.encode.assert_called_once_with(text, disallowed_special=()) + + +@pytest.mark.parametrize("text", [None, "", "ordinary text", "<|endoftext|>"]) +def test_empty_or_precounted_text_does_not_call_encoder(encoder, text): + assert clip_tokens(text, 100, num_input_tokens=len(text or "")) == text + encoder.encode.assert_not_called() + + +@pytest.mark.parametrize( + ("system", "user"), + [ + ("system <|endoftext|>", "user"), + ("system", "user <|endoftext|>"), + ("system {{ text }}", "user {{ text }}"), + ("system", "user"), + ], +) +def test_initial_prompt_count_treats_special_token_literals_as_text(encoder, system, user): + text = "<|endoftext|>" + handler = TokenHandler(object(), {"text": text}, system, user, model="test-model") + rendered = [template.replace("{{ text }}", text) for template in (system, user)] + + assert handler.prompt_tokens == sum(map(len, rendered)) + assert encoder.encode.call_count == 2 + for call, prompt in zip(encoder.encode.call_args_list, rendered, strict=True): + assert call.args == (prompt,) + assert call.kwargs == {"disallowed_special": ()} + + +def test_skills_context_treats_special_token_literals_as_text(): + skill = Skill( + name="tokenizer-guidance", + description="Use when reviewing tokenizer code.", + body="Document the literal <|endoftext|> token spelling.", + ) + + context = format_skills_context([skill], max_tokens=1_000) + + assert "<|endoftext|>" in context From 5968fc3c33425169dea2f032bf1588c3ec88fff8 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:05:14 +0800 Subject: [PATCH 229/238] fix(bitbucket): preserve backslashes in rendered suggestions (#3577) --- pr_agent/git_providers/bitbucket_provider.py | 2 +- .../bitbucket_server_provider.py | 2 +- pr_agent/git_providers/github_provider.py | 2 +- tests/unittest/test_bitbucket_provider.py | 36 +++++++++++++++++++ .../unittest/test_github_provider_comments.py | 31 ++++++++++++++++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index c08aeade1f..3e0be60f6a 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -182,7 +182,7 @@ def publish_code_suggestions(self, code_suggestions: list) -> bool: patch = "\n".join(patch_orig.splitlines()[5:]).strip('\n') diff_code = f"\n\n```diff\n{patch.rstrip()}\n```" # replace ```suggestion ... ``` with diff_code, using regex: - body = re.sub(r'```suggestion.*?```', diff_code, body, flags=re.DOTALL) + body = re.sub(r'```suggestion.*?```', lambda _: diff_code, body, flags=re.DOTALL) except Exception as e: get_logger().exception(f"Bitbucket failed to get diff code for publishing, error: {e}") continue diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index 922485d714..dd8aa4f361 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -168,7 +168,7 @@ def publish_code_suggestions(self, code_suggestions: list) -> bool: patch = "\n".join(patch_orig.splitlines()[5:]).strip('\n') diff_code = f"\n\n```diff\n{patch.rstrip()}\n```" # replace ```suggestion ... ``` with diff_code, using regex: - body = re.sub(r'```suggestion.*?```', diff_code, body, flags=re.DOTALL) + body = re.sub(r'```suggestion.*?```', lambda _: diff_code, body, flags=re.DOTALL) except Exception as e: get_logger().exception(f"Bitbucket failed to get diff code for publishing, error: {e}") continue diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 787f0a0f6b..f3875fdd89 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1966,7 +1966,7 @@ def validate_comments_inside_hunks(self, code_suggestions): patch = "\n".join(patch_orig.splitlines()[5:]).strip('\n') diff_code = f"\n\n
      New proposed code:\n\n```diff\n{patch.rstrip()}\n```" # replace ```suggestion ... ``` with diff_code, using regex: - body = re.sub(r'```suggestion.*?```', diff_code, body, flags=re.DOTALL) + body = re.sub(r'```suggestion.*?```', lambda _: diff_code, body, flags=re.DOTALL) body += "\n\n
      " suggestion['body'] = body get_logger().info(f"Comment was moved to a valid hunk, " diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index bbac645773..c9b6cf6b2e 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -735,6 +735,24 @@ def test_publish_code_suggestions_reports_success(self): for response in responses: response.raise_for_status.assert_called_once_with() + def test_publish_code_suggestions_preserves_backslashes_in_diff(self): + provider = self._provider_for_code_suggestions() + provider.publish_inline_comments = MagicMock(return_value=True) + suggestion = self._code_suggestion(2) + suggestion["body"] = '```suggestion\npattern = r"\\d+"\n```' + suggestion["original_suggestion"] = { + "existing_code": 'pattern = r"\\w+"', + "improved_code": 'pattern = r"\\d+"', + } + + result = provider.publish_code_suggestions([suggestion]) + + assert result is True + post_parameters = provider.publish_inline_comments.call_args.args[0] + assert len(post_parameters) == 1 + assert r"\w+" in post_parameters[0]["body"] + assert r"\d+" in post_parameters[0]["body"] + def test_publish_code_suggestions_reports_http_failures(self): provider = self._provider_for_code_suggestions() responses = [self._inline_comment_response(403), self._inline_comment_response(500)] @@ -1321,6 +1339,24 @@ def test_publish_code_suggestions_reports_success(self): assert result is True assert provider.bitbucket_client.post.call_count == 2 + def test_publish_code_suggestions_preserves_backslashes_in_diff(self): + provider = self._provider_for_code_suggestions() + provider.publish_inline_comments = MagicMock(return_value=True) + suggestion = self._code_suggestion(2) + suggestion["body"] = '```suggestion\npattern = r"\\d+"\n```' + suggestion["original_suggestion"] = { + "existing_code": 'pattern = r"\\w+"', + "improved_code": 'pattern = r"\\d+"', + } + + result = provider.publish_code_suggestions([suggestion]) + + assert result is True + post_parameters = provider.publish_inline_comments.call_args.args[0] + assert len(post_parameters) == 1 + assert r"\w+" in post_parameters[0]["body"] + assert r"\d+" in post_parameters[0]["body"] + def test_publish_code_suggestions_reports_failures_and_continues(self): provider = self._provider_for_code_suggestions() provider.bitbucket_client.post.side_effect = [ diff --git a/tests/unittest/test_github_provider_comments.py b/tests/unittest/test_github_provider_comments.py index 513b03ad31..299ffbe280 100644 --- a/tests/unittest/test_github_provider_comments.py +++ b/tests/unittest/test_github_provider_comments.py @@ -930,3 +930,34 @@ def test_user_comment_authorship_resolves_authenticated_user(): assert provider.supports_review_finding_state() is True assert provider.is_comment_authored_by_pr_agent(comment) is True + + +def test_validate_comments_inside_hunks_preserves_backslashes_in_fallback_diff(): + provider = _make_provider() + provider.get_diff_files = lambda: [ + SimpleNamespace( + filename="src/example.py", + patch="@@ -10,2 +10,2 @@\n-old\n+new", + language="python", + ) + ] + suggestion = { + "body": '**Suggestion:** preserve escapes\n```suggestion\npattern = r"\\1\\n\\\\x"\n```', + "relevant_file": "src/example.py", + "relevant_lines_start": 9, + "relevant_lines_end": 11, + "original_suggestion": { + "existing_code": 'pattern = r"\\1"', + "improved_code": 'pattern = r"\\1\\n\\\\x"', + }, + } + + validated = provider.validate_comments_inside_hunks([suggestion]) + result = validated[0] + + assert result["relevant_lines_start"] == 10 + assert result["relevant_lines_end"] == 11 + assert "```suggestion" not in result["body"] + assert "```diff" in result["body"] + assert r'-pattern = r"\1"' in result["body"] + assert r'+pattern = r"\1\n\\x"' in result["body"] From cc6f2b869f7fc05402d78a8b2f7dc5d133d8bef6 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:50:52 +0545 Subject: [PATCH 230/238] fix(review): keep findings with non-numeric line fields (#3590) --- pr_agent/algo/utils.py | 13 +++++--- tests/unittest/test_convert_to_markdown.py | 38 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 328d8e4acf..abced30823 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -309,11 +309,14 @@ def convert_to_markdown_v2(output_data: dict, if issue_header.lower() == 'possible bug': issue_header = 'Possible Issue' # Make the header less frightening issue_content = issue.get('issue_content', '').strip() - start_line = int(str(issue.get('start_line', 0)).strip()) - end_line = int(str(issue.get('end_line', 0)).strip()) - - relevant_lines_str = extract_relevant_lines_str(end_line, files, relevant_file, start_line, dedent=True) - if git_provider: + try: + start_line = int(str(issue.get('start_line', 0)).strip()) + end_line = int(str(issue.get('end_line', 0)).strip()) + except (TypeError, ValueError): + start_line, end_line = 0, 0 + valid_lines = start_line > 0 and end_line >= start_line + relevant_lines_str = extract_relevant_lines_str(end_line, files, relevant_file, start_line, dedent=True) if valid_lines else "" + if git_provider and valid_lines: reference_link = git_provider.get_line_link(relevant_file, start_line, end_line) else: reference_link = None diff --git a/tests/unittest/test_convert_to_markdown.py b/tests/unittest/test_convert_to_markdown.py index ef4b9ab641..3d3241d9e2 100644 --- a/tests/unittest/test_convert_to_markdown.py +++ b/tests/unittest/test_convert_to_markdown.py @@ -2,6 +2,8 @@ import textwrap from unittest.mock import Mock, patch +import pytest + from pr_agent.algo.comment_identity import PRReviewHeader from pr_agent.algo.utils import _expand_minute_suffix, convert_to_markdown_v2 from pr_agent.tools.pr_description import insert_br_after_x_chars @@ -146,6 +148,42 @@ def test_key_issue_with_non_string_text_is_skipped_without_render_error(self): assert 'Code Smell' not in output get_logger.return_value.exception.assert_not_called() + @pytest.mark.parametrize("field,invalid_value", [ + ("start_line", ""), + ("start_line", "N/A"), + ("start_line", 12.5), + ("start_line", None), + ("start_line", "12-15"), + ("end_line", ""), + ("end_line", "N/A"), + ("end_line", 12.5), + ("end_line", None), + ("end_line", "12-15"), + ]) + def test_key_issue_with_non_numeric_lines_keeps_finding_in_summary(self, field, invalid_value): + """A non-integer start_line/end_line must not silently drop the finding. + + The review summary, inline comments, and the persisted finding state used to + disagree: inline and state tolerated malformed line fields while the summary + dropped the finding. The finding must render in the summary without lines. + """ + valid_other = {'start_line': 30, 'end_line': 14} + valid_other[field] = invalid_value + input_data = {'review': {'key_issues_to_review': [{ + 'relevant_file': 'src/utils.py', + 'issue_header': 'Possible security issue', + 'issue_content': 'Credentials are logged on the error path.', + **valid_other, + }]}} + mock_git_provider = Mock() + mock_git_provider.get_line_link.return_value = 'https://github.com/qodo/pr-agent/pull/1/files#diff-hash' + + output = convert_to_markdown_v2(input_data, git_provider=mock_git_provider) + + assert 'Possible security issue' in output + assert 'Credentials are logged on the error path.' in output + mock_git_provider.get_line_link.assert_not_called() + def test_key_issue_with_omitted_text_keeps_empty_fallback(self): input_data = {'review': {'key_issues_to_review': [{ 'relevant_file': 'src/utils.py', From 5c626b510ab3d96b99bec7b6c62f58f1b42de709 Mon Sep 17 00:00:00 2001 From: Sajjad Ghasemi Date: Tue, 22 Sep 2026 12:36:51 +0330 Subject: [PATCH 231/238] fix(qdrant): batch similar issue uploads (#3559) Co-authored-by: IsmaelMartinez --- pr_agent/tools/pr_similar_issue.py | 7 +- .../test_similar_issue_qdrant_collection.py | 106 +++++++++++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index f3442b0ed4..a7bda0079c 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -852,7 +852,12 @@ def _update_qdrant_with_issues(self, issues_list, repo_name_for_index, ingest=Fa }, ) ) - self.qdrant.upsert(collection_name=self.qdrant_collection_name, points=points) + self.qdrant.upload_points( + collection_name=self.qdrant_collection_name, + points=points, + batch_size=100, + wait=True, + ) get_logger().info('Done') diff --git a/tests/unittest/test_similar_issue_qdrant_collection.py b/tests/unittest/test_similar_issue_qdrant_collection.py index 7c1c8cc406..fce504d74c 100644 --- a/tests/unittest/test_similar_issue_qdrant_collection.py +++ b/tests/unittest/test_similar_issue_qdrant_collection.py @@ -1,11 +1,104 @@ """The qdrant collection name is suffixed so pre-#2323 points cannot surface in results.""" + import inspect +import sys +from types import SimpleNamespace import pr_agent.tools.pr_similar_issue as psi BASE_INDEX_NAME = "codium-ai-pr-agent-issues" +class _PandasSeries(list): + def to_list(self): + return list(self) + + +class _PandasDataFrame: + def __init__(self, records): + self._records = list(records) + self._overrides = {} + + def __getitem__(self, column): + if column in self._overrides: + return _PandasSeries(self._overrides[column]) + return _PandasSeries([row[column] for row in self._records]) + + def __setitem__(self, column, values): + self._overrides[column] = list(values) + + def to_dict(self, orient="records"): + rows = [dict(record) for record in self._records] + for column, values in self._overrides.items(): + for row, value in zip(rows, values, strict=True): + row[column] = value + return rows + + +class _PointStruct: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def test_qdrant_upload_points_uses_batching(monkeypatch): + calls = {} + + class FakeQdrant: + def upload_points(self, **kwargs): + calls.update(kwargs) + + fake_qdrant_models = SimpleNamespace(PointStruct=_PointStruct) + fake_qdrant_client = SimpleNamespace(models=fake_qdrant_models) + + monkeypatch.setitem( + sys.modules, + "pandas", + SimpleNamespace(DataFrame=_PandasDataFrame), + ) + monkeypatch.setitem( + sys.modules, + "qdrant_client", + fake_qdrant_client, + ) + monkeypatch.setitem( + sys.modules, + "qdrant_client.models", + fake_qdrant_models, + ) + + tool = psi.PRSimilarIssue.__new__(psi.PRSimilarIssue) + tool.qdrant = FakeQdrant() + tool.qdrant_collection_name = "test-collection" + tool.max_issues_to_scan = 100 + tool.token_handler = SimpleNamespace(count_tokens=lambda text: 1) + + issue = SimpleNamespace( + pull_request=False, + user=SimpleNamespace(login="user"), + created_at="2020-01-01", + number=7, + get_comments=lambda: [], + ) + + monkeypatch.setattr( + psi.PRSimilarIssue, + "_process_issue", + lambda self, issue: ("Issue body", [], issue.number), + ) + monkeypatch.setattr( + psi, + "_embed_with_fallback", + lambda texts: [[0.5, 0.5] for _ in texts], + ) + + tool._update_qdrant_with_issues([issue], "example-repo") + + assert calls["collection_name"] == "test-collection" + assert calls["batch_size"] == 100 + assert calls["wait"] is True + assert len(calls["points"]) == 2 + + def test_collection_name_has_the_v2_suffix(): """The v2 suffix keeps the new index separate from the pre-#2323 collection.""" assert psi._qdrant_collection_name(BASE_INDEX_NAME) == "codium-ai-pr-agent-issues-v2" @@ -16,7 +109,9 @@ def test_suffix_is_scoped_to_qdrant_only(): source = inspect.getsource(psi) qdrant_only_call_sites = [ "if not self.qdrant.collection_exists(collection_name=self.qdrant_collection_name):", - "self.qdrant.upsert(collection_name=self.qdrant_collection_name, points=points)", + "self.qdrant.upload_points(", + "batch_size=100", + "wait=True", ] for call_site in qdrant_only_call_sites: @@ -34,7 +129,12 @@ def test_docs_carry_the_upgrade_note(): """The issue requires the upgrade note to ship with the similar_issue docs.""" from pathlib import Path - doc = Path(psi.__file__).resolve().parents[2] / "docs" / "docs" / "tools" / "similar_issues.md" + doc = ( + Path(psi.__file__).resolve().parents[2] + / "docs" + / "docs" + / "tools" + / "similar_issues.md" + ) content = doc.read_text(encoding="utf-8") - assert "codium-ai-pr-agent-issues-v2" in content From eb9e0c2d6fcd206ccadfc40ec17d8dd73e3304e6 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Tue, 22 Sep 2026 05:02:12 -0500 Subject: [PATCH 232/238] fix(mosaico): classify diff files by language (#3597) Co-authored-by: Oleksii Tumanov --- pr_agent/mosaico/dispatch.py | 12 +++--- tests/unittest/test_mosaico_router.py | 61 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/pr_agent/mosaico/dispatch.py b/pr_agent/mosaico/dispatch.py index de111b945c..92b447de28 100644 --- a/pr_agent/mosaico/dispatch.py +++ b/pr_agent/mosaico/dispatch.py @@ -23,6 +23,7 @@ import aiohttp +from pr_agent.algo.language_handler import build_language_file_matcher from pr_agent.config_loader import get_settings from pr_agent.log import get_logger from pr_agent.mosaico.diff_provider import parse_unified_diff @@ -352,14 +353,15 @@ async def _run_ask(target: str, question: str) -> "RouteResult": def _simple_languages(files) -> dict: - """Best-effort language map (extension -> count) for get_main_pr_language; tolerant - of empties (downstream handles an empty dict).""" + """Return configured language-name counts, tolerating an empty file list.""" + language_map = get_settings().get("language_extension_map_org", {}) or {} + get_language = build_language_file_matcher(language_map) langs = {} for f in files: name = getattr(f, "filename", "") or "" - if "." in name: - ext = name.rsplit(".", 1)[1].lower() - langs[ext] = langs.get(ext, 0) + 1 + language = get_language(name) + if language: + langs[language] = langs.get(language, 0) + 1 return langs diff --git a/tests/unittest/test_mosaico_router.py b/tests/unittest/test_mosaico_router.py index 8b2e3ca29d..2fd45f87ad 100644 --- a/tests/unittest/test_mosaico_router.py +++ b/tests/unittest/test_mosaico_router.py @@ -8,6 +8,7 @@ import aiohttp import pytest +from pr_agent.algo.language_handler import sort_files_by_main_languages from pr_agent.config_loader import get_settings, global_settings from pr_agent.mosaico import dispatch from pr_agent.mosaico.dispatch import ( @@ -258,6 +259,66 @@ async def fake_handle_request(self, pr_url, request, notify=None): assert mi and [f.filename for f in mi["files"]] == ["foo.py"] assert mi["title"] == "Supplied diff" + @pytest.mark.asyncio + async def test_diff_classifies_languages_for_packing(self, monkeypatch, restore_settings): + captured = {} + filenames = [ + "src/core.py", + "src/extra.PY", + "ui.js", + "Dockerfile", + "build/Makefile", + "native/upper.C", + "native/lower.c", + "cmake/tool.cmake.in", + "templates/device.axi.erb", + "assets/README.unknown", + ] + language_diff = "\n".join( + f"""diff --git a/{filename} b/{filename} +index 1111111..2222222 100644 +--- a/{filename} ++++ b/{filename} +@@ -1 +1 @@ +-old ++new""" + for filename in filenames + ) + + async def fake_handle_request(self, pr_url, request, notify=None): + mosaico_input = global_settings.get("MOSAICO.INPUT") + captured["languages"] = dict(mosaico_input["languages"]) + captured["buckets"] = sort_files_by_main_languages( + mosaico_input["languages"], mosaico_input["files"] + ) + _set_artifact("DIFF REVIEW") + return True + + from pr_agent.agent.pr_agent import PRAgent + monkeypatch.setattr(PRAgent, "handle_request", fake_handle_request) + + assert await route_and_run(f"review the following\n{language_diff}") == "DIFF REVIEW" + assert captured["languages"] == { + "Python": 2, + "JavaScript": 1, + "Dockerfile": 1, + "Makefile": 1, + "C++": 1, + "C": 1, + "CMake": 1, + "NetLinx+ERB": 1, + } + buckets = captured["buckets"] + assert buckets[0]["language"] == "Python" + assert [f.filename for f in buckets[0]["files"]] == ["src/core.py", "src/extra.PY"] + assert [f.filename for f in buckets[-1]["files"]] == ["assets/README.unknown"] + packed_filenames = [f.filename for bucket in buckets for f in bucket["files"]] + assert sorted(packed_filenames) == sorted(filenames) + assert len(packed_filenames) == len(set(packed_filenames)) + + def test_language_classification_tolerates_empty_input(self): + assert dispatch._simple_languages([]) == {} + @pytest.mark.asyncio async def test_unparseable_diff_returns_empty_fallback(self, monkeypatch, restore_settings): # looks like a diff (has a fence) but parse yields nothing From 4ac48b2bdedaf5521a3fc23c0d9292450dd7a938 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Tue, 22 Sep 2026 05:03:08 -0500 Subject: [PATCH 233/238] fix: preserve backslashes in diagram markers (#3599) Co-authored-by: Alex Tumanov <6143578+oleksii-tumanov@users.noreply.github.com> --- pr_agent/tools/pr_description.py | 2 +- .../test_pr_description_output_core.py | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 9802cf28d9..629c4e13a4 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -740,7 +740,7 @@ def _prepare_pr_answer_with_markers(self) -> Tuple[str, str]: # Add support for pr_agent:diagram marker (plain and HTML comment formats) ai_diagram = self.data.get('changes_diagram') if ai_diagram: - body = re.sub(r'|pr_agent:diagram', ai_diagram, body) + body = re.sub(r'|pr_agent:diagram', lambda _match: ai_diagram, body) return title, body diff --git a/tests/unittest/test_pr_description_output_core.py b/tests/unittest/test_pr_description_output_core.py index af2fabc5bf..b848d224ca 100644 --- a/tests/unittest/test_pr_description_output_core.py +++ b/tests/unittest/test_pr_description_output_core.py @@ -29,7 +29,7 @@ from pr_agent.algo.types import FilePatchInfo from pr_agent.algo.utils import process_description from pr_agent.config_loader import get_settings -from pr_agent.tools.pr_description import PRDescription +from pr_agent.tools.pr_description import PRDescription, sanitize_diagram KEYS_FIX = ["filename:", "language:", "changes_summary:", "changes_title:", "description:", "title:"] @@ -278,6 +278,34 @@ def test_diagram_marker_replaces_both_plain_and_html_comment(self, mock_get_sett assert "" not in body assert "pr_agent:diagram" not in body.replace("```mermaid", "") + @pytest.mark.parametrize("marker", ["pr_agent:diagram", ""]) + @pytest.mark.parametrize("label", [r"C:\Users\demo", r"\d+", r"literal\ntext"]) + @patch("pr_agent.tools.pr_description.get_settings") + def test_diagram_marker_preserves_backslashes(self, mock_get_settings, label, marker): + mock_get_settings.return_value = _settings() + diagram = sanitize_diagram(f'```mermaid\nflowchart LR\nA["{label}"] --> B\n```') + obj = self._obj_with_user_description( + f"Before\n{marker}\nAfter", + {"title": "AI", "changes_diagram": diagram}, + ) + + _, body = obj._prepare_pr_answer_with_markers() + + assert body == f"Before\n{diagram}\nAfter" + + @patch("pr_agent.tools.pr_description.get_settings") + def test_empty_diagram_leaves_marker_unchanged(self, mock_get_settings): + mock_get_settings.return_value = _settings() + original_body = "Before\npr_agent:diagram\nAfter" + obj = self._obj_with_user_description( + original_body, + {"title": "AI", "changes_diagram": ""}, + ) + + _, body = obj._prepare_pr_answer_with_markers() + + assert body == original_body + @patch("pr_agent.tools.pr_description.get_settings") def test_title_falls_back_when_generate_ai_title_disabled(self, mock_get_settings): mock_get_settings.return_value = _settings(generate_ai_title=False) From 560c9f26f73da1a3ac655da9868310977a75eb23 Mon Sep 17 00:00:00 2001 From: Alex Tumanov Date: Tue, 22 Sep 2026 05:04:06 -0500 Subject: [PATCH 234/238] fix: keep changelog wait cooperative (#3601) --- pr_agent/git_providers/github_provider.py | 6 +- pr_agent/tools/pr_update_changelog.py | 27 +- .../test_changelog_code_fence_stripping.py | 20 +- .../test_github_provider_file_content.py | 25 ++ tests/unittest/test_pr_update_changelog.py | 252 ++++++++++++++++-- 5 files changed, 285 insertions(+), 45 deletions(-) diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index f3875fdd89..da29fb97d3 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -11,6 +11,7 @@ from urllib.parse import quote, urlparse from github import Auth, Github, GithubException, GithubIntegration, GithubRetry, RateLimitExceededException +from github.Commit import Commit from github.Issue import Issue from retry.api import retry_call from starlette_context import context @@ -1691,19 +1692,20 @@ def get_pr_file_content(self, file_path: str, branch: str, propagate_errors: boo def create_or_update_pr_file( self, file_path: str, branch: str, contents="", message="" - ) -> None: + ) -> Commit: try: file_obj = self._get_repo().get_contents(file_path, ref=branch) sha1=file_obj.sha except Exception: sha1="" - self.repo_obj.update_file( + response = self.repo_obj.update_file( path=file_path, message=message, content=contents, sha=sha1, branch=branch, ) + return response["commit"] def _get_pr_file_content(self, file: FilePatchInfo, sha: str, path: str = None) -> str: return self.get_pr_file_content(path or file.filename, sha) diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index 8b7f3b980c..2a4ed65756 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -1,8 +1,8 @@ +import asyncio import copy import re from datetime import date from functools import partial -from time import sleep from typing import Tuple from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler @@ -151,7 +151,7 @@ async def run(self): if get_settings().config.publish_output: if self.commit_changelog: - self._push_changelog_update(new_file_content, answer) + await self._push_changelog_update(new_file_content, answer) else: changelog_comment = f"**Changelog updates:** 🔄\n\n{answer}" if self.push_skipped_reason: @@ -273,7 +273,7 @@ def _prepare_changelog_update(self) -> Tuple[str, str]: return new_file_content, answer - def _push_changelog_update(self, new_file_content, answer): + async def _push_changelog_update(self, new_file_content, answer): if not self.git_provider.is_supported("push_code"): # Its only caller already gates on self.commit_changelog, which is False # whenever this capability is missing; kept local so the guard holds even @@ -284,7 +284,7 @@ def _push_changelog_update(self, new_file_content, answer): else: commit_message = "Update CHANGELOG.md" try: - self.git_provider.create_or_update_pr_file( + written_commit = self.git_provider.create_or_update_pr_file( file_path="CHANGELOG.md", branch=self.git_provider.get_pr_branch(), contents=new_file_content, @@ -294,17 +294,30 @@ def _push_changelog_update(self, new_file_content, answer): self._publish_changelog_write_error_fallback(answer) raise - sleep(5) # wait for the file to be updated + try: + await asyncio.sleep(5) # wait for the file to be updated + except asyncio.CancelledError: + # Preserve the user-visible fallback after a successful write while keeping + # cancellation authoritative. + try: + if self.git_provider.supports_changelog_update_review(): + self.git_provider.publish_comment(f"**Changelog updates: 🔄**\n\n{answer}") + except Exception as feedback_error: + get_logger().exception( + f"Failed to publish changelog fallback during cancellation: {feedback_error}" + ) + raise try: if self.git_provider.supports_changelog_update_review(): - last_commit_id = list(self.git_provider.pr.get_commits())[-1] + if written_commit is None: + raise ValueError("The changelog write did not return a commit for review") d = dict( body="CHANGELOG.md update", path="CHANGELOG.md", line=max(2, len(answer.splitlines())), start_line=1, ) - self.git_provider.pr.create_review(commit=last_commit_id, event="COMMENT", comments=[d]) + self.git_provider.pr.create_review(commit=written_commit, event="COMMENT", comments=[d]) except Exception: # we can't create a review for some reason, let's just publish a comment self.git_provider.publish_comment(f"**Changelog updates: 🔄**\n\n{answer}") diff --git a/tests/unittest/test_changelog_code_fence_stripping.py b/tests/unittest/test_changelog_code_fence_stripping.py index 89f47835b9..673e403588 100644 --- a/tests/unittest/test_changelog_code_fence_stripping.py +++ b/tests/unittest/test_changelog_code_fence_stripping.py @@ -86,7 +86,10 @@ def test_the_commit_hint_is_appended_when_not_committing(): @pytest.fixture def committing_tool(monkeypatch): """The real push path, with only the provider and the 5s settle sleep replaced.""" - monkeypatch.setattr("pr_agent.tools.pr_update_changelog.sleep", lambda seconds: None) + async def no_sleep(seconds): + assert seconds == 5 + + monkeypatch.setattr("pr_agent.tools.pr_update_changelog.asyncio.sleep", no_sleep) monkeypatch.setattr(get_settings().config, "git_provider", "local", raising=False) provider = MagicMock() provider.get_pr_branch.return_value = "feature/retry" @@ -97,12 +100,13 @@ def committing_tool(monkeypatch): return tool, provider -def test_the_committed_file_keeps_the_inline_code_span(committing_tool): +@pytest.mark.asyncio +async def test_the_committed_file_keeps_the_inline_code_span(committing_tool): tool, provider = committing_tool tool.prediction = "## 2026-09-06\n\n### Fixed\n- Handle `None` in `parse()`" new_file_content, answer = tool._prepare_changelog_update() - tool._push_changelog_update(new_file_content, answer) + await tool._push_changelog_update(new_file_content, answer) committed = provider.create_or_update_pr_file.call_args.kwargs["contents"] assert committed.startswith("## 2026-09-06\n\n### Fixed\n- Handle `None` in `parse()`") @@ -110,12 +114,13 @@ def test_the_committed_file_keeps_the_inline_code_span(committing_tool): assert committed.endswith(EXISTING) -def test_the_committed_file_is_written_to_the_pr_branch(committing_tool): +@pytest.mark.asyncio +async def test_the_committed_file_is_written_to_the_pr_branch(committing_tool): tool, provider = committing_tool tool.prediction = "## 2026-09-06\n- Handle `None`" new_file_content, answer = tool._prepare_changelog_update() - tool._push_changelog_update(new_file_content, answer) + await tool._push_changelog_update(new_file_content, answer) kwargs = provider.create_or_update_pr_file.call_args.kwargs assert kwargs["file_path"] == "CHANGELOG.md" @@ -123,13 +128,14 @@ def test_the_committed_file_is_written_to_the_pr_branch(committing_tool): assert kwargs["message"] == "[skip ci] Update CHANGELOG.md" -def test_a_fenced_answer_is_committed_without_its_fence(committing_tool): +@pytest.mark.asyncio +async def test_a_fenced_answer_is_committed_without_its_fence(committing_tool): """The model often wraps the whole entry; the fence must not reach the file.""" tool, provider = committing_tool tool.prediction = "```markdown\n## 2026-09-06\n- Handle `None`\n```" new_file_content, answer = tool._prepare_changelog_update() - tool._push_changelog_update(new_file_content, answer) + await tool._push_changelog_update(new_file_content, answer) committed = provider.create_or_update_pr_file.call_args.kwargs["contents"] assert committed.startswith("## 2026-09-06\n- Handle `None`") diff --git a/tests/unittest/test_github_provider_file_content.py b/tests/unittest/test_github_provider_file_content.py index 1007072761..c83ee7de67 100644 --- a/tests/unittest/test_github_provider_file_content.py +++ b/tests/unittest/test_github_provider_file_content.py @@ -47,3 +47,28 @@ def test_get_pr_file_content_strict_reraises_original_error(error): provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) assert exc_info.value is error + + +def test_create_or_update_pr_file_returns_written_commit(): + provider = GithubProvider.__new__(GithubProvider) + provider.repo_obj = MagicMock() + provider.repo_obj.get_contents.return_value.sha = "file-sha" + provider._get_repo = MagicMock(return_value=provider.repo_obj) + written_commit = object() + provider.repo_obj.update_file.return_value = {"content": object(), "commit": written_commit} + + result = provider.create_or_update_pr_file( + file_path="CHANGELOG.md", + branch="feature-branch", + contents="new content", + message="Update CHANGELOG.md", + ) + + assert result is written_commit + provider.repo_obj.update_file.assert_called_once_with( + path="CHANGELOG.md", + message="Update CHANGELOG.md", + content="new content", + sha="file-sha", + branch="feature-branch", + ) diff --git a/tests/unittest/test_pr_update_changelog.py b/tests/unittest/test_pr_update_changelog.py index 651634fdd7..dc0109b584 100644 --- a/tests/unittest/test_pr_update_changelog.py +++ b/tests/unittest/test_pr_update_changelog.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -510,7 +511,7 @@ async def test_run_with_push_support(self, changelog_tool, mock_git_provider): with patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings, \ patch('pr_agent.tools.pr_update_changelog.retry_with_fallback_models') as mock_retry, \ - patch('pr_agent.tools.pr_update_changelog.sleep'): + patch('pr_agent.tools.pr_update_changelog.asyncio.sleep', new_callable=AsyncMock) as sleep: mock_settings.return_value.pr_update_changelog.push_changelog_changes = True mock_settings.return_value.pr_update_changelog.get.return_value = True @@ -526,10 +527,12 @@ async def test_run_with_push_support(self, changelog_tool, mock_git_provider): call_args = mock_git_provider.create_or_update_pr_file.call_args assert call_args[1]['file_path'] == 'CHANGELOG.md' assert call_args[1]['branch'] == 'feature-branch' + sleep.assert_awaited_once_with(5) - def test_push_changelog_update_creates_review_when_supported(self, changelog_tool, mock_git_provider): + @pytest.mark.asyncio + async def test_push_changelog_update_creates_review_when_supported(self, changelog_tool, mock_git_provider): """When supported, pushing the changelog creates a PR review on the committed changes.""" - mock_git_provider.create_or_update_pr_file = MagicMock() + mock_git_provider.create_or_update_pr_file = MagicMock(return_value="commit-123") mock_git_provider.get_pr_branch.return_value = "feature-branch" mock_git_provider.supports_changelog_update_review.return_value = True mock_git_provider.pr.get_commits.return_value = ["commit-123"] @@ -538,11 +541,11 @@ def test_push_changelog_update_creates_review_when_supported(self, changelog_too answer = "Line 1\nLine 2" with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( - "pr_agent.tools.pr_update_changelog.sleep" - ): + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ) as sleep: mock_settings.return_value.pr_update_changelog.get.return_value = True - changelog_tool._push_changelog_update(new_content, answer) + await changelog_tool._push_changelog_update(new_content, answer) mock_git_provider.create_or_update_pr_file.assert_called_once_with( file_path="CHANGELOG.md", @@ -563,9 +566,171 @@ def test_push_changelog_update_creates_review_when_supported(self, changelog_too ], ) mock_git_provider.publish_comment.assert_not_called() + mock_git_provider.pr.get_commits.assert_not_called() + sleep.assert_awaited_once_with(5) + + @pytest.mark.asyncio + async def test_push_changelog_update_yields_during_wait_before_review( + self, changelog_tool, mock_git_provider + ): + events = [] + peer_progressed = asyncio.Event() + mock_git_provider.create_or_update_pr_file.side_effect = ( + lambda **_kwargs: events.append("write") or "commit-123" + ) + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.pr.create_review.side_effect = lambda **_kwargs: events.append("review") + + async def peer_work(): + events.append("peer progress") + peer_progressed.set() + + async def cooperative_wait(delay): + assert delay == 5 + events.append("wait") + await peer_progressed.wait() + + peer_task = asyncio.create_task(peer_work()) + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=cooperative_wait + ) as sleep: + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update("new content", "answer") + + assert await peer_task is None + sleep.assert_awaited_once_with(5) + mock_git_provider.pr.get_commits.assert_not_called() + assert events == ["write", "wait", "peer progress", "review"] + + @pytest.mark.asyncio + async def test_push_changelog_update_reviews_exact_written_commit_when_branch_advances( + self, changelog_tool, mock_git_provider + ): + written_commit = object() + branch_advanced = asyncio.Event() + mock_git_provider.create_or_update_pr_file.return_value = written_commit + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.pr.get_commits.return_value = [object()] + + async def cooperative_wait(delay): + assert delay == 5 + branch_advanced.set() + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=cooperative_wait + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update("new content", "answer") + + assert branch_advanced.is_set() + mock_git_provider.pr.get_commits.assert_not_called() + assert mock_git_provider.pr.create_review.call_args.kwargs["commit"] is written_commit + + @pytest.mark.asyncio + async def test_push_changelog_update_cancellation_after_write_publishes_fallback_and_propagates( + self, changelog_tool, mock_git_provider + ): + entered_wait = asyncio.Event() + mock_git_provider.create_or_update_pr_file.return_value = object() + mock_git_provider.supports_changelog_update_review.return_value = True + + async def pending_wait(delay): + assert delay == 5 + entered_wait.set() + await asyncio.Event().wait() + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=pending_wait + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + update_task = asyncio.create_task( + changelog_tool._push_changelog_update("new content", "answer") + ) + await entered_wait.wait() + update_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(update_task) + + mock_git_provider.publish_comment.assert_called_once_with( + "**Changelog updates: 🔄**\n\nanswer" + ) + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.pr.create_review.assert_not_called() + + @pytest.mark.asyncio + async def test_run_cancellation_after_write_publishes_fallback_before_cleanup( + self, changelog_tool, mock_git_provider + ): + entered_wait = asyncio.Event() + mock_git_provider.create_or_update_pr_file.return_value = object() + mock_git_provider.supports_changelog_update_review.return_value = True + changelog_tool.commit_changelog = True + changelog_tool.prediction = "## v1.1.0\n- New feature" + + async def pending_wait(_delay): + entered_wait.set() + await asyncio.Event().wait() + + with ( + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), + patch("pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=pending_wait), + ): + mock_settings.return_value.config.publish_output = True + mock_settings.return_value.pr_update_changelog.get.return_value = True + mock_settings.return_value.get.return_value = {} + run_task = asyncio.create_task(changelog_tool.run()) + await entered_wait.wait() + run_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(run_task) + + assert mock_git_provider.publish_comment.call_args_list == [ + call("Preparing changelog updates...", is_temporary=True), + call("**Changelog updates: 🔄**\n\n## v1.1.0\n- New feature"), + ] + mock_git_provider.pr.create_review.assert_not_called() + mock_git_provider.remove_initial_comment.assert_called_once_with() + + @pytest.mark.asyncio + async def test_push_changelog_update_cancellation_fallback_failure_does_not_mask_cancellation( + self, changelog_tool, mock_git_provider + ): + entered_wait = asyncio.Event() + feedback_error = RuntimeError("comment unavailable") + mock_git_provider.create_or_update_pr_file.return_value = object() + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.publish_comment.side_effect = feedback_error + + async def pending_wait(_delay): + entered_wait.set() + await asyncio.Event().wait() + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=pending_wait + ), patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger: + mock_settings.return_value.pr_update_changelog.get.return_value = True + update_task = asyncio.create_task( + changelog_tool._push_changelog_update("new content", "answer") + ) + await entered_wait.wait() + update_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(update_task) + + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to publish changelog fallback during cancellation: comment unavailable" + ) + mock_git_provider.pr.create_review.assert_not_called() @pytest.mark.parametrize("error_type", [HTTPError, Timeout]) - def test_push_changelog_update_retains_output_and_stops_success_follow_up_after_write_failure( + @pytest.mark.asyncio + async def test_push_changelog_update_retains_output_and_stops_success_follow_up_after_write_failure( self, changelog_tool, mock_git_provider, error_type ): write_error = error_type("write failed") @@ -574,16 +739,16 @@ def test_push_changelog_update_retains_output_and_stops_success_follow_up_after_ mock_git_provider.supports_changelog_update_review.return_value = True with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( - "pr_agent.tools.pr_update_changelog.sleep" + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock ) as sleep: mock_settings.return_value.pr_update_changelog.get.return_value = True with pytest.raises(error_type) as raised: - changelog_tool._push_changelog_update("new content", "answer") + await changelog_tool._push_changelog_update("new content", "answer") assert raised.value is write_error mock_git_provider.create_or_update_pr_file.assert_called_once() - sleep.assert_not_called() + sleep.assert_not_awaited() mock_git_provider.pr.get_commits.assert_not_called() mock_git_provider.pr.create_review.assert_not_called() mock_git_provider.publish_comment.assert_called_once() @@ -592,7 +757,8 @@ def test_push_changelog_update_retains_output_and_stops_success_follow_up_after_ assert "could not be confirmed" in fallback assert "not pushed" not in fallback - def test_push_changelog_update_fallback_failure_does_not_mask_write_error( + @pytest.mark.asyncio + async def test_push_changelog_update_fallback_failure_does_not_mask_write_error( self, changelog_tool, mock_git_provider ): write_error = Timeout("write outcome unknown") @@ -606,7 +772,7 @@ def test_push_changelog_update_fallback_failure_does_not_mask_write_error( pytest.raises(Timeout) as raised, ): mock_settings.return_value.pr_update_changelog.get.return_value = True - changelog_tool._push_changelog_update("new content", "answer") + await changelog_tool._push_changelog_update("new content", "answer") assert raised.value is write_error mock_git_provider.publish_comment.assert_called_once() @@ -628,7 +794,7 @@ async def test_run_preserves_write_failure_after_temporary_comment_cleanup( with ( patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), - patch("pr_agent.tools.pr_update_changelog.sleep") as sleep, + patch("pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock) as sleep, ): mock_settings.return_value.config.publish_output = True mock_settings.return_value.pr_update_changelog.get.return_value = True @@ -647,11 +813,12 @@ async def test_run_preserves_write_failure_after_temporary_comment_cleanup( ), ] mock_git_provider.remove_initial_comment.assert_called_once_with() - sleep.assert_not_called() + sleep.assert_not_awaited() mock_git_provider.pr.get_commits.assert_not_called() mock_git_provider.pr.create_review.assert_not_called() - def test_push_changelog_update_skips_review_when_not_supported(self, changelog_tool, mock_git_provider): + @pytest.mark.asyncio + async def test_push_changelog_update_skips_review_when_not_supported(self, changelog_tool, mock_git_provider): """A provider without the capability is never asked for a commit-scoped review.""" mock_git_provider.create_or_update_pr_file = MagicMock() mock_git_provider.get_pr_branch.return_value = "feature-branch" @@ -660,11 +827,11 @@ def test_push_changelog_update_skips_review_when_not_supported(self, changelog_t answer = "Changes made" with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( - "pr_agent.tools.pr_update_changelog.sleep" - ): + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ) as sleep: mock_settings.return_value.pr_update_changelog.get.return_value = True - changelog_tool._push_changelog_update(new_content, answer) + await changelog_tool._push_changelog_update(new_content, answer) mock_git_provider.create_or_update_pr_file.assert_called_once_with( file_path="CHANGELOG.md", @@ -674,26 +841,52 @@ def test_push_changelog_update_skips_review_when_not_supported(self, changelog_t ) mock_git_provider.pr.get_commits.assert_not_called() mock_git_provider.publish_comment.assert_not_called() + sleep.assert_awaited_once_with(5) - def test_push_changelog_update_falls_back_to_comment_on_review_exception(self, changelog_tool, mock_git_provider): - """When creating a review raises an exception, it falls back to publishing a comment.""" - mock_git_provider.create_or_update_pr_file = MagicMock() + @pytest.mark.asyncio + async def test_push_changelog_update_falls_back_to_comment_when_written_commit_is_unavailable( + self, changelog_tool, mock_git_provider + ): + """Do not guess the commit when a review-capable write returns none.""" + mock_git_provider.create_or_update_pr_file = MagicMock(return_value=None) mock_git_provider.get_pr_branch.return_value = "feature-branch" mock_git_provider.supports_changelog_update_review.return_value = True - mock_git_provider.pr.get_commits.side_effect = Exception("API error") new_content = "# Updated changelog content" answer = "Changes made" with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( - "pr_agent.tools.pr_update_changelog.sleep" + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock ): mock_settings.return_value.pr_update_changelog.get.return_value = True - changelog_tool._push_changelog_update(new_content, answer) + await changelog_tool._push_changelog_update(new_content, answer) + mock_git_provider.pr.get_commits.assert_not_called() mock_git_provider.publish_comment.assert_called_once_with(f"**Changelog updates: 🔄**\n\n{answer}") - def test_push_changelog_update(self, changelog_tool, mock_git_provider): + @pytest.mark.asyncio + async def test_push_changelog_update_falls_back_to_comment_on_review_exception( + self, changelog_tool, mock_git_provider + ): + """When creating a review raises an exception, it falls back to publishing a comment.""" + mock_git_provider.create_or_update_pr_file = MagicMock(return_value="commit-123") + mock_git_provider.get_pr_branch.return_value = "feature-branch" + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.pr.create_review.side_effect = Exception("API error") + answer = "Changes made" + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update("new content", answer) + + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.publish_comment.assert_called_once_with(f"**Changelog updates: 🔄**\n\n{answer}") + + @pytest.mark.asyncio + async def test_push_changelog_update(self, changelog_tool, mock_git_provider): """Test the push changelog update functionality.""" # Arrange mock_git_provider.create_or_update_pr_file = MagicMock() @@ -702,12 +895,12 @@ def test_push_changelog_update(self, changelog_tool, mock_git_provider): answer = "Changes made" with patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings, \ - patch('pr_agent.tools.pr_update_changelog.sleep'): + patch('pr_agent.tools.pr_update_changelog.asyncio.sleep', new_callable=AsyncMock): mock_settings.return_value.pr_update_changelog.get.return_value = True # Act - changelog_tool._push_changelog_update(new_content, answer) + await changelog_tool._push_changelog_update(new_content, answer) # Assert mock_git_provider.create_or_update_pr_file.assert_called_once_with( @@ -717,7 +910,8 @@ def test_push_changelog_update(self, changelog_tool, mock_git_provider): message="[skip ci] Update CHANGELOG.md" ) - def test_push_changelog_update_never_calls_create_or_update_pr_file_when_push_code_is_unsupported( + @pytest.mark.asyncio + async def test_push_changelog_update_never_calls_create_or_update_pr_file_when_push_code_is_unsupported( self, changelog_tool, mock_git_provider): """A provider that declines `push_code` (e.g. restricted_mode) must never reach `create_or_update_pr_file`, even if a future caller invokes this method directly @@ -727,7 +921,7 @@ def test_push_changelog_update_never_calls_create_or_update_pr_file_when_push_co new_content = "# Updated changelog content" answer = "Changes made" - changelog_tool._push_changelog_update(new_content, answer) + await changelog_tool._push_changelog_update(new_content, answer) mock_git_provider.create_or_update_pr_file.assert_not_called() From 9d7b5c7cd8d4e8e0d9b1027fc21f77566e562555 Mon Sep 17 00:00:00 2001 From: Takao <44014204+high-tail@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:04:56 +0900 Subject: [PATCH 235/238] refactor(cloud_auth): deduplicate repeated logic (#3607) --- pr_agent/algo/ai_handlers/cloud_auth.py | 46 ++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/pr_agent/algo/ai_handlers/cloud_auth.py b/pr_agent/algo/ai_handlers/cloud_auth.py index 74f282be5f..fe77f40a4b 100644 --- a/pr_agent/algo/ai_handlers/cloud_auth.py +++ b/pr_agent/algo/ai_handlers/cloud_auth.py @@ -379,6 +379,15 @@ def _require_litellm_interface(interface, name: str, methods=()): ) +def _rebind_to_globals(native, native_globals): + """Rebind a native function onto captured globals, keeping its own defaults.""" + bound = FunctionType( + native.__code__, native_globals, native.__name__, native.__defaults__, native.__closure__, + ) + bound.__kwdefaults__ = copy.copy(native.__kwdefaults__) + return bound + + _azure_oidc_request = ContextVar("pr_agent_azure_oidc_request", default=None) _azure_ad_responses_request = ContextVar("pr_agent_azure_ad_responses_request", default=None) _azure_oidc_bridge = None @@ -433,8 +442,7 @@ def set_cache(key, *args, **kwargs): # and refresh/TTL behavior. Only the outer exchange's inputs are request-owned. native_globals["os"] = SimpleNamespace(getenv=captured_getenv) native_globals["azure_ad_cache"] = SimpleNamespace(get_cache=get_cache, set_cache=set_cache) - exchange = FunctionType(native.__code__, native_globals, native.__name__, native.__defaults__, native.__closure__) - exchange.__kwdefaults__ = copy.copy(native.__kwdefaults__) + exchange = _rebind_to_globals(native, native_globals) return exchange( selector, azure_client_id=environment["AZURE_CLIENT_ID"], @@ -476,11 +484,7 @@ def cached(tenant_id, client_id, client_secret, scope): # Retain native secret-selector resolution, but never use the native # authority-free provider cache. Credentials retain authority on refresh. globals_view = {**original_entra.__globals__, "_cached_entra_id_token_provider": cached} - factory = FunctionType( - original_entra.__code__, globals_view, original_entra.__name__, - original_entra.__defaults__, original_entra.__closure__, - ) - factory.__kwdefaults__ = copy.copy(original_entra.__kwdefaults__) + factory = _rebind_to_globals(original_entra, globals_view) return factory(tenant_id, client_id, client_secret, scope) def password(client_id, azure_username, azure_password, scope="https://cognitiveservices.azure.com/.default"): @@ -571,11 +575,7 @@ def getenv(name, default=None): native_globals["os"] = SimpleNamespace(getenv=getenv) native_globals["get_secret_str"] = get_secret_str native_globals["get_azure_ad_token_from_oidc"] = exchange - bound = FunctionType( - original_resolver.__code__, native_globals, original_resolver.__name__, - original_resolver.__defaults__, original_resolver.__closure__, - ) - bound.__kwdefaults__ = copy.copy(original_resolver.__kwdefaults__) + bound = _rebind_to_globals(original_resolver, native_globals) return bound(litellm_params) @wraps(original_resolver) @@ -619,11 +619,7 @@ def initialize(*args, **kwargs): ): return original_initialize(*args, **kwargs) native_globals = _azure_oidc_companion_globals(original_initialize.__globals__, context) - factory = FunctionType( - original_initialize.__code__, native_globals, original_initialize.__name__, - original_initialize.__defaults__, original_initialize.__closure__, - ) - factory.__kwdefaults__ = copy.copy(original_initialize.__kwdefaults__) + factory = _rebind_to_globals(original_initialize, native_globals) return factory(*args, **kwargs) @wraps(original_cache_key) @@ -1212,6 +1208,14 @@ def _raw_guard_has_header_only_auth(provider, params, headers): return not ((tenant and client and secret) or (username and password and client)) +def _require_http_auth_signature(original): + """Reject a native HTTP validator that no longer exposes the request auth parameters.""" + signature = inspect.signature(original) + if not {"api_key", "headers", "litellm_params"}.issubset(signature.parameters): + raise RuntimeError("LiteLLM's native HTTP authentication interface is incompatible") + return signature + + def _install_raw_api_key_guard_override_bridge(provider): """Undo generated auth headers without reopening a native key resolver.""" if provider not in ("azure_ai", "ragflow", "xai"): @@ -1225,9 +1229,7 @@ def _install_raw_api_key_guard_override_bridge(provider): marker = "_pr_agent_original_raw_override_validate_environment" if getattr(original, marker, None) is not None: return - signature = inspect.signature(original) - if not {"api_key", "headers", "litellm_params"}.issubset(signature.parameters): - raise RuntimeError("LiteLLM's native HTTP authentication interface is incompatible") + signature = _require_http_auth_signature(original) def validate_environment(self, *args, **kwargs): if _raw_api_key_guard_provider.get() != provider or type(self) is not config: @@ -1281,9 +1283,7 @@ def _install_raw_api_key_guard_bridge(): marker = "_pr_agent_original_raw_validate_environment" if getattr(original, marker, None) is not None: return - signature = inspect.signature(original) - if not {"api_key", "headers", "litellm_params"}.issubset(signature.parameters): - raise RuntimeError("LiteLLM's native HTTP authentication interface is incompatible") + signature = _require_http_auth_signature(original) def validate_environment(self, *args, **kwargs): provider = _raw_api_key_guard_provider.get() From 7957d331351ddd087dedc7c1f951f6172b36334a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:05:13 +0100 Subject: [PATCH 236/238] chore(ci): bump the actions-minor-and-patch group with 6 updates (#3594) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-and-test.yaml | 8 ++++---- .github/workflows/codeql.yml | 4 ++-- .github/workflows/e2e_tests.yaml | 4 ++-- .github/workflows/publish.yml | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 293859273b..5c1596ded9 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -27,11 +27,11 @@ jobs: - id: dockerx name: Setup Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - id: build name: Build dev docker - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./docker/Dockerfile @@ -56,7 +56,7 @@ jobs: exit 1 fi - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -64,7 +64,7 @@ jobs: # Smoke-start github_app (gunicorn + uvicorn) with no secrets; GET / → 200. - id: build-smoke name: Build github_app docker - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./docker/Dockerfile diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7d1b57daec..ce13ce7e54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,12 +33,12 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} queries: security-extended,security-and-quality - - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/e2e_tests.yaml b/.github/workflows/e2e_tests.yaml index 77255e4b7e..22f292c9ac 100644 --- a/.github/workflows/e2e_tests.yaml +++ b/.github/workflows/e2e_tests.yaml @@ -14,11 +14,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - id: build name: Build dev docker - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./docker/Dockerfile diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2b14c3bcf7..142b13f0fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -179,12 +179,12 @@ jobs: - name: Set version in pyproject.toml run: python scripts/set_pyproject_version.py "${{ needs.prepare.outputs.version }}" - - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 with: # amd64 is the runner's native arch; only arm64 needs emulation. platforms: arm64 - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: @@ -208,7 +208,7 @@ jobs: echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - id: push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ${{ matrix.dockerfile }} From 223ac5121195f73e35f558862b08c8599e0b52d5 Mon Sep 17 00:00:00 2001 From: Utsab Dahal <134686066+utsab345@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:52:39 +0545 Subject: [PATCH 237/238] fix(action): ignore non-command comments (#3604) --- pr_agent/servers/github_action_runner.py | 11 +++++++++++ .../test_github_action_runner_core.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index 0eb3c855f5..b98d63af7b 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -352,6 +352,17 @@ async def run_action(): get_logger().info("Skipping comment event from a bot sender to avoid a feedback loop") return comment_body = event_payload.get("comment", {}).get("body") + # Skip comments that are not commands, mirroring the webhook guard + # in github_app.py. Otherwise a plain comment is lexed as an unknown + # command, PRAgent.handle_request returns False and the action exits 1. + if comment_body and isinstance(comment_body, str) and not comment_body.lstrip().startswith("/"): + if '/ask' in comment_body and comment_body.strip().startswith('> ![image]'): + comment_body_split = comment_body.split('/ask') + comment_body = '/ask' + comment_body_split[1] + ' \n' + comment_body_split[0].strip().lstrip('>') + get_logger().info(f"Reformatting comment_body so command is at the beginning: {comment_body}") + else: + get_logger().info("Ignoring comment not starting with /") + return try: if GITHUB_EVENT_NAME == "pull_request_review_comment": if '/ask' in comment_body: diff --git a/tests/unittest/test_github_action_runner_core.py b/tests/unittest/test_github_action_runner_core.py index 1c2e944749..9d7529c96e 100644 --- a/tests/unittest/test_github_action_runner_core.py +++ b/tests/unittest/test_github_action_runner_core.py @@ -685,6 +685,25 @@ async def test_issue_comment_from_user_is_processed(monkeypatch, tmp_path, resto assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/review")] + +@pytest.mark.asyncio +async def test_issue_comment_not_starting_with_slash_is_skipped( + monkeypatch, tmp_path, restore_github_settings +): + """Regression for #3603: a plain comment must not fail the action run.""" + handled = [] + _patch_issue_comment_deps(monkeypatch, handled) + monkeypatch.setenv("GITHUB_EVENT_NAME", "issue_comment") + monkeypatch.setenv( + "GITHUB_EVENT_PATH", + str(_write_issue_comment_event_with_body(tmp_path, "thanks!")), + ) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + await github_action_runner.run_action() + + assert handled == [] # non-command comment skipped; action run stays green + @pytest.mark.asyncio async def test_issue_comment_on_plain_issue_is_dispatched(monkeypatch, tmp_path, restore_github_settings): handled = [] From dfa6db89f9466b9ccc442934170904a7168301ae Mon Sep 17 00:00:00 2001 From: Utsab Dahal Date: Wed, 23 Sep 2026 08:05:38 +0545 Subject: [PATCH 238/238] fix(gitea): fail closed when webhook secret is unset --- docs/docs/installation/gitea.md | 2 ++ pr_agent/servers/gitea_app.py | 27 +++++++++++-------- pr_agent/settings/.secrets_template.toml | 2 +- tests/unittest/test_gitea_comment_webhooks.py | 21 +++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/docs/installation/gitea.md b/docs/docs/installation/gitea.md index 827c362ecc..31ed76828b 100644 --- a/docs/docs/installation/gitea.md +++ b/docs/docs/installation/gitea.md @@ -10,6 +10,8 @@ WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))") ``` + The webhook secret is required: if `GITEA.WEBHOOK_SECRET` is not configured, the server rejects every incoming webhook with HTTP 403. + 4. Clone this repository: ```bash diff --git a/pr_agent/servers/gitea_app.py b/pr_agent/servers/gitea_app.py index 83b6aeb8a5..3fb0afbe13 100644 --- a/pr_agent/servers/gitea_app.py +++ b/pr_agent/servers/gitea_app.py @@ -50,18 +50,23 @@ async def get_body(request: Request): # Verify webhook signature webhook_secret = getattr(get_settings().gitea, 'webhook_secret', None) - if webhook_secret: - body_bytes = await request.body() - signature_header = request.headers.get('x-gitea-signature', None) - if not signature_header: - get_logger().error("Missing signature header") - raise HTTPException(status_code=400, detail="Missing signature header") + if not webhook_secret: + # Refuse unauthenticated webhooks. Silently accepting requests when the + # secret is not configured used to allow any internet caller to forge + # Gitea events and trigger expensive AI commands against arbitrary PRs. + get_logger().error("Rejecting Gitea webhook: GITEA.WEBHOOK_SECRET is not configured") + raise HTTPException(status_code=403, detail="Webhook secret not configured") + body_bytes = await request.body() + signature_header = request.headers.get('x-gitea-signature', None) + if not signature_header: + get_logger().error("Missing signature header") + raise HTTPException(status_code=400, detail="Missing signature header") - try: - verify_signature(body_bytes, webhook_secret, f"sha256={signature_header}") - except Exception as ex: - get_logger().error(f"Invalid signature: {ex}") - raise HTTPException(status_code=401, detail="Invalid signature") + try: + verify_signature(body_bytes, webhook_secret, f"sha256={signature_header}") + except Exception as ex: + get_logger().error(f"Invalid signature: {ex}") + raise HTTPException(status_code=401, detail="Invalid signature") return body diff --git a/pr_agent/settings/.secrets_template.toml b/pr_agent/settings/.secrets_template.toml index 02e03c697c..d70f5215e9 100644 --- a/pr_agent/settings/.secrets_template.toml +++ b/pr_agent/settings/.secrets_template.toml @@ -86,7 +86,7 @@ shared_secret = "" # webhook secret [gitea] # Gitea personal access token personal_access_token="" -webhook_secret="" # webhook secret +webhook_secret="" # webhook secret. Required. Webhooks are rejected with HTTP 403 when it is unset. [otel] # OpenTelemetry OTLP exporter configuration (optional) diff --git a/tests/unittest/test_gitea_comment_webhooks.py b/tests/unittest/test_gitea_comment_webhooks.py index b835a339d4..9a448aa7d1 100644 --- a/tests/unittest/test_gitea_comment_webhooks.py +++ b/tests/unittest/test_gitea_comment_webhooks.py @@ -127,3 +127,24 @@ async def test_unsigned_or_invalid_comment_is_rejected_before_dispatch(agent, si assert caught.value.status_code == status assert tasks.tasks == [] agent.handle_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unconfigured_secret_rejects_every_webhook(monkeypatch): + # Like the GitHub app, the Gitea webhook must fail closed when the secret is + # not configured instead of accepting unauthenticated events that any caller + # could forge to trigger expensive commands. + settings = SimpleNamespace(gitea=SimpleNamespace(webhook_secret="")) + monkeypatch.setattr(gitea_app, "get_settings", lambda: settings) + monkeypatch.setattr(gitea_app, "global_settings", settings) + monkeypatch.setattr(gitea_app, "context", {}) + agent = SimpleNamespace(handle_request=AsyncMock()) + monkeypatch.setattr(gitea_app, "PRAgent", lambda: agent) + + tasks = BackgroundTasks() + with pytest.raises(HTTPException) as caught: + await gitea_app.handle_gitea_webhooks(tasks, _request(_payload()), Response()) + + assert caught.value.status_code == 403 + assert tasks.tasks == [] + agent.handle_request.assert_not_awaited()