agex-cli 0.28.0__tar.gz → 0.29.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. {agex_cli-0.28.0 → agex_cli-0.29.0}/.github/workflows/test.yml +6 -0
  2. {agex_cli-0.28.0 → agex_cli-0.29.0}/CHANGELOG.md +18 -0
  3. {agex_cli-0.28.0 → agex_cli-0.29.0}/CLAUDE.md +12 -3
  4. {agex_cli-0.28.0 → agex_cli-0.29.0}/PKG-INFO +9 -1
  5. {agex_cli-0.28.0 → agex_cli-0.29.0}/README.md +8 -0
  6. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/specs/2026-05-10-agex-pr-design.md +29 -6
  7. {agex_cli-0.28.0 → agex_cli-0.29.0}/pyproject.toml +1 -1
  8. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/SKILL.md +30 -2
  9. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_open_result.md.j2 +5 -0
  10. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_reply_result.md.j2 +5 -1
  11. agex_cli-0.29.0/src/devex/commands/pr/assets/templates/pr_review_result.md.j2 +11 -0
  12. agex_cli-0.29.0/src/devex/commands/pr/scripts/_webhook.py +143 -0
  13. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/open_.py +14 -1
  14. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/reply.py +16 -1
  15. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/review.py +13 -1
  16. agex_cli-0.29.0/src/devex/core/webhook.py +122 -0
  17. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_open.py +108 -0
  18. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_reply.py +36 -0
  19. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_review.py +37 -0
  20. agex_cli-0.29.0/tests/commands/pr/test_webhook_helper.py +218 -0
  21. agex_cli-0.29.0/tests/conftest.py +61 -0
  22. agex_cli-0.29.0/tests/core/test_webhook.py +157 -0
  23. agex_cli-0.29.0/tests/core/test_webhook_live.py +48 -0
  24. {agex_cli-0.28.0 → agex_cli-0.29.0}/uv.lock +1 -1
  25. agex_cli-0.28.0/src/devex/commands/pr/assets/templates/pr_review_result.md.j2 +0 -5
  26. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/agent-config/SKILL.md +0 -0
  27. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
  28. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
  29. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
  30. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
  31. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/cicd/SKILL.md +0 -0
  32. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  33. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/SKILL.md +0 -0
  34. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  35. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  36. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  37. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  38. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  39. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  40. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
  41. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
  42. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/run-tests/SKILL.md +0 -0
  43. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  44. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  45. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  46. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  47. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  48. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/think/SKILL.md +0 -0
  49. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/think/scripts/think.sh +0 -0
  50. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/version-bump/SKILL.md +0 -0
  51. {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  52. {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/frames/devex-now-turns-a-push-into-continuous-pr-manageme.json +0 -0
  53. {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/frames/every-devex-command-now-closes-with-a-deterministi.json +0 -0
  54. {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/plans/devex-now-turns-a-push-into-continuous-pr-manageme.json +0 -0
  55. {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/plans/every-devex-command-now-closes-with-a-deterministi.json +0 -0
  56. {agex_cli-0.28.0 → agex_cli-0.29.0}/.flake8 +0 -0
  57. {agex_cli-0.28.0 → agex_cli-0.29.0}/.github/workflows/publish.yml +0 -0
  58. {agex_cli-0.28.0 → agex_cli-0.29.0}/.gitignore +0 -0
  59. {agex_cli-0.28.0 → agex_cli-0.29.0}/.python-version +0 -0
  60. {agex_cli-0.28.0 → agex_cli-0.29.0}/LICENSE +0 -0
  61. {agex_cli-0.28.0 → agex_cli-0.29.0}/culture.yaml +0 -0
  62. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/plans/2026-05-29-devex-now-turns-a-push-into-continuous-pr-manageme.md +0 -0
  63. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/plans/2026-05-29-every-devex-command-now-closes-with-a-deterministi.md +0 -0
  64. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/skill-sources.md +0 -0
  65. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/specs/2026-05-29-devex-now-turns-a-push-into-continuous-pr-manageme.md +0 -0
  66. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/specs/2026-05-29-every-devex-command-now-closes-with-a-deterministi.md +0 -0
  67. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/plans/2026-04-18-agex-v0.1.md +0 -0
  68. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/plans/2026-05-10-agex-pr.md +0 -0
  69. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/specs/2026-04-18-agex-design.md +0 -0
  70. {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/specs/2026-04-26-agex-doctor.md +0 -0
  71. {agex_cli-0.28.0 → agex_cli-0.29.0}/sonar-project.properties +0 -0
  72. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/__init__.py +0 -0
  73. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/__main__.py +0 -0
  74. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/__init__.py +0 -0
  75. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/acp/__init__.py +0 -0
  76. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/acp/probe.py +0 -0
  77. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/acp.yaml +0 -0
  78. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/claude-code.yaml +0 -0
  79. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/codex.yaml +0 -0
  80. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/copilot.yaml +0 -0
  81. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/claude_code/__init__.py +0 -0
  82. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/claude_code/probe.py +0 -0
  83. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/codex/__init__.py +0 -0
  84. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/codex/probe.py +0 -0
  85. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/copilot/__init__.py +0 -0
  86. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/copilot/probe.py +0 -0
  87. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/cli.py +0 -0
  88. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/__init__.py +0 -0
  89. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/SKILL.md +0 -0
  90. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/__init__.py +0 -0
  91. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/__init__.py +0 -0
  92. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/acp.yaml +0 -0
  93. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/claude-code.yaml +0 -0
  94. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/codex.yaml +0 -0
  95. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/copilot.yaml +0 -0
  96. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/report.md.j2 +0 -0
  97. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/references/design.md +0 -0
  98. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/__init__.py +0 -0
  99. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/_footer.py +0 -0
  100. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/doctor.py +0 -0
  101. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/next_step.py +0 -0
  102. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/SKILL.md +0 -0
  103. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/__init__.py +0 -0
  104. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/__init__.py +0 -0
  105. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/acp.yaml +0 -0
  106. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/claude-code.yaml +0 -0
  107. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/codex.yaml +0 -0
  108. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/copilot.yaml +0 -0
  109. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/topics/devex.md +0 -0
  110. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/references/.gitkeep +0 -0
  111. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/__init__.py +0 -0
  112. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/_footer.py +0 -0
  113. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/explain.py +0 -0
  114. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/next_step.py +0 -0
  115. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/SKILL.md +0 -0
  116. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/__init__.py +0 -0
  117. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/__init__.py +0 -0
  118. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/acp.yaml +0 -0
  119. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/claude-code.yaml +0 -0
  120. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/codex.yaml +0 -0
  121. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/copilot.yaml +0 -0
  122. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/hooks/claude-code.json +0 -0
  123. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/references/.gitkeep +0 -0
  124. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/scripts/__init__.py +0 -0
  125. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/scripts/install.py +0 -0
  126. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/scripts/next_step.py +0 -0
  127. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/SKILL.md +0 -0
  128. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/__init__.py +0 -0
  129. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/__init__.py +0 -0
  130. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/acp.yaml +0 -0
  131. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/claude-code.yaml +0 -0
  132. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/codex.yaml +0 -0
  133. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/copilot.yaml +0 -0
  134. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/table.md.j2 +0 -0
  135. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/references/.gitkeep +0 -0
  136. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/__init__.py +0 -0
  137. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/_footer.py +0 -0
  138. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/next_step.py +0 -0
  139. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/read.py +0 -0
  140. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/write.py +0 -0
  141. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/SKILL.md +0 -0
  142. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/__init__.py +0 -0
  143. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/__init__.py +0 -0
  144. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/acp.yaml +0 -0
  145. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/claude-code.yaml +0 -0
  146. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/codex.yaml +0 -0
  147. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/copilot.yaml +0 -0
  148. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/menu.md.j2 +0 -0
  149. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/cicd/SKILL.md +0 -0
  150. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/gamify/SKILL.md +0 -0
  151. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/gamify/assets/skill-template/claude-code/SKILL.md +0 -0
  152. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/introspect/SKILL.md +0 -0
  153. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/introspect/assets/skill-template/claude-code/SKILL.md +0 -0
  154. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/levelup/SKILL.md +0 -0
  155. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/levelup/assets/skill-template/claude-code/SKILL.md +0 -0
  156. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/visualize/SKILL.md +0 -0
  157. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/visualize/assets/skill-template/claude-code/SKILL.md +0 -0
  158. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/references/.gitkeep +0 -0
  159. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/scripts/__init__.py +0 -0
  160. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/scripts/learn.py +0 -0
  161. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/scripts/next_step.py +0 -0
  162. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/SKILL.md +0 -0
  163. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/__init__.py +0 -0
  164. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/__init__.py +0 -0
  165. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/__init__.py +0 -0
  166. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/acp.yaml +0 -0
  167. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/claude-code.yaml +0 -0
  168. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/codex.yaml +0 -0
  169. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/copilot.yaml +0 -0
  170. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/sections.md.j2 +0 -0
  171. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/references/.gitkeep +0 -0
  172. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/__init__.py +0 -0
  173. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/_footer.py +0 -0
  174. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/next_step.py +0 -0
  175. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/overview.py +0 -0
  176. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/__init__.py +0 -0
  177. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/__init__.py +0 -0
  178. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/__init__.py +0 -0
  179. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/acp.yaml +0 -0
  180. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/claude-code.yaml +0 -0
  181. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/codex.yaml +0 -0
  182. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/copilot.yaml +0 -0
  183. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/rules/__init__.py +0 -0
  184. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/rules/lint_rules.py +0 -0
  185. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/rules/next_step_rules.py +0 -0
  186. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/__init__.py +0 -0
  187. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/delta.md.j2 +0 -0
  188. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/lint_result.md.j2 +0 -0
  189. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_await_detached.md.j2 +0 -0
  190. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_briefing.md.j2 +0 -0
  191. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/__init__.py +0 -0
  192. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_await_worker.py +0 -0
  193. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_deploy.py +0 -0
  194. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_detach.py +0 -0
  195. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_footer.py +0 -0
  196. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_journal.py +0 -0
  197. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_qodo.py +0 -0
  198. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_readiness.py +0 -0
  199. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_sonar.py +0 -0
  200. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/await_.py +0 -0
  201. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/delta.py +0 -0
  202. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/lint.py +0 -0
  203. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/read.py +0 -0
  204. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/SKILL.md +0 -0
  205. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/__init__.py +0 -0
  206. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/__init__.py +0 -0
  207. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/__init__.py +0 -0
  208. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/acp.yaml +0 -0
  209. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/claude-code.yaml +0 -0
  210. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/codex.yaml +0 -0
  211. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/copilot.yaml +0 -0
  212. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/scripts/__init__.py +0 -0
  213. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/scripts/push.py +0 -0
  214. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/__init__.py +0 -0
  215. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/_jsonl.py +0 -0
  216. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/__init__.py +0 -0
  217. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/backends/__init__.py +0 -0
  218. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/backends/neutral.yaml +0 -0
  219. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/footer.md.j2 +0 -0
  220. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/backend.py +0 -0
  221. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/capabilities.py +0 -0
  222. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/config.py +0 -0
  223. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/footer.py +0 -0
  224. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/github.py +0 -0
  225. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/hook_io.py +0 -0
  226. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/journal.py +0 -0
  227. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/paths.py +0 -0
  228. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/prog.py +0 -0
  229. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/render.py +0 -0
  230. {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/skill_loader.py +0 -0
  231. {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/.claude/settings.json +0 -0
  232. {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/.claude/skills +0 -0
  233. {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/CLAUDE.md +0 -0
  234. {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/README.md +0 -0
  235. {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/culture.yaml +0 -0
  236. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/__init__.py +0 -0
  237. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/backends/__init__.py +0 -0
  238. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/backends/test_claude_code_probe.py +0 -0
  239. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/backends/test_stub_probes.py +0 -0
  240. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/__init__.py +0 -0
  241. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/__init__.py +0 -0
  242. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/.gitkeep +0 -0
  243. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/pr_checks_42.json +0 -0
  244. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/pr_comments_42.json +0 -0
  245. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/qodo_summary_comment.html +0 -0
  246. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/journals/dogfood_40.jsonl +0 -0
  247. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_await.py +0 -0
  248. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_await_detach.py +0 -0
  249. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_delta.py +0 -0
  250. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_deploy.py +0 -0
  251. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_footer.py +0 -0
  252. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_lint.py +0 -0
  253. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_lint_rules.py +0 -0
  254. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_qodo.py +0 -0
  255. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_read.py +0 -0
  256. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_readiness.py +0 -0
  257. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/push/__init__.py +0 -0
  258. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/push/test_push.py +0 -0
  259. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/push/test_push_backends.py +0 -0
  260. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_doctor.py +0 -0
  261. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_explain.py +0 -0
  262. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_gamify.py +0 -0
  263. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_hook.py +0 -0
  264. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_learn.py +0 -0
  265. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_overview.py +0 -0
  266. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_prog_propagation.py +0 -0
  267. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/__init__.py +0 -0
  268. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_backend.py +0 -0
  269. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_capabilities.py +0 -0
  270. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_config.py +0 -0
  271. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_footer.py +0 -0
  272. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_github.py +0 -0
  273. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_hook_io.py +0 -0
  274. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_journal.py +0 -0
  275. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_paths.py +0 -0
  276. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_prog.py +0 -0
  277. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_render.py +0 -0
  278. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_resolve_backend.py +0 -0
  279. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_skill_loader.py +0 -0
  280. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_version_lookup.py +0 -0
  281. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/empty/.gitkeep +0 -0
  282. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/hooks.json +0 -0
  283. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/settings.json +0 -0
  284. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/skills/bad/SKILL.md +0 -0
  285. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/skills/broken-yaml/SKILL.md +0 -0
  286. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/.claude/hooks.json +0 -0
  287. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/.claude/settings.json +0 -0
  288. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/.claude/skills/example/SKILL.md +0 -0
  289. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/CLAUDE.md +0 -0
  290. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_cli_dispatch.py +0 -0
  291. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_cli_errors.py +0 -0
  292. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_cli_smoke.py +0 -0
  293. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_footer_guarantee.py +0 -0
  294. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_footer_hints.py +0 -0
  295. {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_skill_md_consistency.py +0 -0
@@ -54,6 +54,12 @@ jobs:
54
54
  - run: uv venv
55
55
  - run: uv pip install -e ".[dev]"
56
56
  - run: uv run pytest --cov=src/devex --cov-report=xml --cov-report=term
57
+ env:
58
+ # Opt-in live-webhook dogfood (tests/core/test_webhook_live.py). Wired
59
+ # only into this single ubuntu/3.12 job — NOT the 3x4 `test` matrix —
60
+ # so a real Discord POST fires at most once per CI run. Empty when the
61
+ # secret is unset (forks / not configured) -> the test skips.
62
+ DEVEX_PR_WEBHOOK_TEST_URL: ${{ secrets.DEVEX_PR_WEBHOOK_TEST_URL }}
57
63
  # Pinned to v6 to match the sibling agentfront repo's working scan.
58
64
  # The v7+/v8 scanner engine 404s on `api.sonarcloud.io/analysis/analyses`
59
65
  # against SonarQube Cloud; v6 posts to sonarcloud.io directly.
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.29.0] - 2026-06-03
11
+
12
+ ### Added
13
+
14
+ - `devex pr open` / `pr reply` / `pr review` post an opt-in, best-effort JSON webhook notification (`{event, repo, id, subject, content}`) to a configured webhook. Discord webhooks (auto-detected by host) get a `{content, embeds}` payload; any other URL gets the raw fields (`[pr.webhook].format = auto|discord|generic` overrides). New `core/webhook.py` (stdlib-`urllib`, the only non-`gh` egress) and `commands/pr/scripts/_webhook.py` orchestrator. New journal events `pr_webhook_posted` / `pr_webhook_failed`.
15
+ - Webhook URL is read only from the environment (a secret): `DEVEX_PR_WEBHOOK_URL` (primary) or the var named by `[pr.webhook].url_env` — never stored in `.devex/config.toml`.
16
+
17
+ ### Changed
18
+
19
+ - Design invariant #4: the `pr` namespace gains a single opt-in, env-gated, fail-open outbound webhook POST as its only non-`gh` network egress. Unset env = no network call (default behavior unchanged).
20
+ - `core/webhook.py` is now a **generic** transport (`post(url, payload)` + URL classifiers) with no `pr`-namespace knowledge; the `DEVEX_PR_WEBHOOK_URL` env var, `[pr.webhook]` config, and PR payload/format shaping moved to `commands/pr/scripts/_webhook.py`, keeping `core/` command-agnostic (PR #74 review).
21
+
22
+ ### Fixed
23
+
24
+ - Webhook POST no longer follows redirects (a 3xx is treated as failed), closing a redirect-based SSRF / scheme-guard bypass (PR #74 review).
25
+ - `_webhook.notify()` is now genuinely fail-open: a malformed `.devex/config.toml`, a `gh` failure resolving the repo slug, or a journal-write error can no longer abort `pr open` / `pr reply` / `pr review` (PR #74 review).
26
+ - A whitespace-only `DEVEX_PR_WEBHOOK_URL` now counts as unset (disabled) instead of triggering a `gh` call and a failed POST (PR #74 review).
27
+
10
28
  ## [0.28.0] - 2026-05-29
11
29
 
12
30
  ### Added
@@ -19,7 +19,7 @@ Read the spec before any non-trivial change — the design invariants below are
19
19
  1. **Zero LLM calls inside devex.** All output is deterministic markdown from Jinja templates + Python.
20
20
  2. **Markdown is the only output format.** No `--json` flag.
21
21
  3. **`--agent <backend>` is required** on backend-sensitive commands. The CLI never auto-detects.
22
- 4. **Side effects only in** `gamify`, `gamify --uninstall`, `hook write`, `pr open`, `pr reply`, `pr review`, `pr read` (journal writes), `pr await` (journal + `--detach` marker writes under `.devex/data/pr/<pr>/` and the detached poller subprocess), `push` (see below), and first-run `.devex/` init. Everything else is read-only. The `devex pr` namespace allows scoped network I/O (via `gh`), bounded `--wait` sleep, and — for `pr await --detach` — a detached background process that pays that sleep outside the agent session; a deliberate carve-out from the no-network/no-sleep invariants. `devex push` performs a `git push` of the current branch (push-only — it never stages or commits); a deliberate carve-out from the no-mutation invariant, introducing `git push` as a new allowed side-effect class.
22
+ 4. **Side effects only in** `gamify`, `gamify --uninstall`, `hook write`, `pr open`, `pr reply`, `pr review`, `pr read` (journal writes), `pr await` (journal + `--detach` marker writes under `.devex/data/pr/<pr>/` and the detached poller subprocess), `push` (see below), and first-run `.devex/` init. Everything else is read-only. The `devex pr` namespace allows scoped network I/O (via `gh`), bounded `--wait` sleep, and — for `pr await --detach` — a detached background process that pays that sleep outside the agent session; a deliberate carve-out from the no-network/no-sleep invariants. `devex push` performs a `git push` of the current branch (push-only — it never stages or commits); a deliberate carve-out from the no-mutation invariant, introducing `git push` as a new allowed side-effect class. **`pr open` / `pr reply` / `pr review` also fire an opt-in, best-effort outbound webhook POST** (devex's only non-`gh` network egress, via `core/webhook.py`) carrying `{event, repo, id, subject, content}` to a Discord or generic webhook — but **only when `DEVEX_PR_WEBHOOK_URL` (or a config-named env var) is set**. Unconfigured = no network call (the default is unchanged); a failed POST never aborts the PR action (exit stays 0).
23
23
  5. **"Unsupported" is success** — exit 0 with a markdown notice that links to the issue tracker, not a non-zero exit.
24
24
  6. **Skills are authored by the agent, not shipped by devex.** `devex learn <topic>` teaches; `devex explain <topic>` describes; devex never writes a user skill file on the agent's behalf in v0.1.
25
25
 
@@ -47,9 +47,13 @@ Every non-silent command ends with a deterministic, per-backend **"Next step:"**
47
47
 
48
48
  ## `devex pr` namespace (v0.17.0+)
49
49
 
50
- `lint`, `open`, `read`, `reply`, `review`, `await`, `delta`. Each command ends with a deterministic "Next step:" footer. The `pr` namespace allows scoped network I/O (via `gh`) and bounded `--wait` sleep — a deliberate carve-out from the no-network/no-sleep invariants. `pr open` (non-draft, new PR) and `pr review` post the Qodo `/agentic_review` trigger comment; the legacy `/improve` is deprecated and never emitted. The trigger string lives in one place: `commands/pr/scripts/review.QODO_REVIEW_TRIGGER`. `pr await --detach` / `--check` (issue #64) move the bounded poll out of the agent session: `--detach` forks a detached worker (`commands/pr/scripts/_await_worker.py`) that writes the verdict to a marker (`commands/pr/scripts/_detach.py`, atomic `os.replace`), and `--check` reads it back without sleeping. Key modules:
50
+ `lint`, `open`, `read`, `reply`, `review`, `await`, `delta`. Each command ends with a deterministic "Next step:" footer. The `pr` namespace allows scoped network I/O (via `gh`) and bounded `--wait` sleep — a deliberate carve-out from the no-network/no-sleep invariants. `pr open` (non-draft, new PR) and `pr review` post the Qodo `/agentic_review` trigger comment; the legacy `/improve` is deprecated and never emitted. The trigger string lives in one place: `commands/pr/scripts/review.QODO_REVIEW_TRIGGER`. `pr await --detach` / `--check` (issue #64) move the bounded poll out of the agent session: `--detach` forks a detached worker (`commands/pr/scripts/_await_worker.py`) that writes the verdict to a marker (`commands/pr/scripts/_detach.py`, atomic `os.replace`), and `--check` reads it back without sleeping.
51
+
52
+ **Webhook notifications (opt-in, env-gated):** `pr open` (`pr_opened`), `pr reply` (`pr_replied`), and `pr review` (`pr_review_triggered`) each fire one best-effort JSON POST to a configured webhook with `{event, repo, id, subject, content}`. The secret URL comes only from the environment — `DEVEX_PR_WEBHOOK_URL` (primary) or the var named by `[pr.webhook].url_env` — never from the config file. Discord webhooks are auto-detected by host and get a `{content, embeds}` payload; everything else gets raw fields; `[pr.webhook].format = auto|discord|generic` overrides. Fail-open: a failed POST never aborts the verb. Unset env = no network call at all (so the no-network default and every existing test are unaffected). `pr await` is intentionally **not** wired. Key modules:
51
53
 
52
54
  - `core/github.py` — thin `gh` shellout wrapper; future zero-trust httpx swap touches only this file.
55
+ - `core/webhook.py` — **generic** stdlib-`urllib` transport: `post(url, payload)` + `is_discord_url` / `is_http_url`. Knows HTTP and Discord-the-service, but **nothing** about the `pr` namespace (no env var, no config, no PR fields) — so `core/` stays command-agnostic. Refuses redirects (a 3xx → `FAILED`, blocking redirect-based SSRF); never raises. The only non-`gh` egress, and the second module the future zero-trust httpx swap touches.
56
+ - `commands/pr/scripts/_webhook.py` — owns the devex-pr specifics: the `DEVEX_PR_WEBHOOK_URL` env var, `[pr.webhook]` config, the PR payload + discord/generic shaping. `notify()` is fully **fail-open** — resolve-then-gate (no `gh`/network when disabled), and every error (bad config, `gh` failure, journal write) is swallowed so a webhook problem can't abort the verb. Journals `pr_webhook_posted`/`pr_webhook_failed` (never the URL/payload).
53
57
  - `commands/pr/scripts/_detach.py` — await-marker read/write (atomic) + the detached-subprocess spawn helper.
54
58
  - `core/journal.py` — nested-stream JSONL append/load for `.devex/data/<dir>/<stream>.jsonl`.
55
59
  - `core/backend.resolve_backend()` — `--agent` resolution with `culture.yaml` fallback.
@@ -84,11 +88,16 @@ uv run devex explain explain
84
88
 
85
89
  # Coverage (matches the `sonarcloud` job in test.yml; SonarCloud reads coverage.xml)
86
90
  uv run pytest --cov=src/devex --cov-report=xml --cov-report=term
91
+
92
+ # Live webhook dogfood (real POST). Skipped unless DEVEX_PR_WEBHOOK_TEST_URL is set —
93
+ # a SEPARATE var from the production DEVEX_PR_WEBHOOK_URL, so the suite never fires a
94
+ # real configured webhook. Set it to a throwaway Discord/test webhook to run it:
95
+ DEVEX_PR_WEBHOOK_TEST_URL=<test-webhook-url> uv run pytest tests/core/test_webhook_live.py
87
96
  ```
88
97
 
89
98
  ## CI surface
90
99
 
91
- - `.github/workflows/test.yml` — matrix: 3 OS × 4 Python (3.10–3.13) running `uv run pytest`. Also runs (1) a `sonarcloud` job that generates `coverage.xml` (`pytest --cov`, repo-relative paths via `pyproject [tool.coverage.run]`) and runs the SHA-pinned `sonarqube-scan-action` (needs the `SONAR_TOKEN` repo secret); and (2) a `version-check` job on PRs that fails (with a sticky `<!-- version-check -->` comment) when `pyproject.toml`'s version on the PR matches the one on `main` and any code file under `src/` / `tests/` / `pyproject.toml` changed. Docs-only PRs skip the version check.
100
+ - `.github/workflows/test.yml` — matrix: 3 OS × 4 Python (3.10–3.13) running `uv run pytest`. Also runs (1) a `sonarcloud` job that generates `coverage.xml` (`pytest --cov`, repo-relative paths via `pyproject [tool.coverage.run]`) and runs the SHA-pinned `sonarqube-scan-action` (needs the `SONAR_TOKEN` repo secret); and (2) a `version-check` job on PRs that fails (with a sticky `<!-- version-check -->` comment) when `pyproject.toml`'s version on the PR matches the one on `main` and any code file under `src/` / `tests/` / `pyproject.toml` changed. Docs-only PRs skip the version check. The `sonarcloud` job's `pytest` step also receives `DEVEX_PR_WEBHOOK_TEST_URL` from the same-named repo secret, so the opt-in live-webhook test (`tests/core/test_webhook_live.py`) runs **once** there (single ubuntu/3.12 job, not the matrix) when the secret is set, and skips otherwise — a real webhook POST fires at most once per CI run.
92
101
  - `.github/workflows/publish.yml` — builds sdist + wheel. PRs publish a per-PR dev version to TestPyPI (sticky install-command comment); pushes to `main` publish the stable version to TestPyPI (canary), then an `autotag` job pushes `v<version>` if missing, which gates the inline `publish-pypi` + `github-release` jobs. No manual tagging — bumping `pyproject.toml` is the release signal.
93
102
  - SonarCloud runs in **CI-based analysis** mode (Automatic Analysis is off — the two are mutually exclusive). The `sonarcloud` job in `test.yml` uploads coverage + triggers the scan via `sonarqube-scan-action`, reading `sonar-project.properties` and the `SONAR_TOKEN` secret. The quality gate decorates PRs and gates merges.
94
103
  - All third-party actions are **pinned to full commit SHAs** with trailing `# vN` comments (rule `githubactions:S7637`). Keep new actions pinned the same way.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agex-cli
3
- Version: 0.28.0
3
+ Version: 0.29.0
4
4
  Summary: Agent-operated developer-experience CLI — deterministic per-backend markdown briefings for autonomous agents.
5
5
  Project-URL: Homepage, https://culture.dev/devex/
6
6
  Project-URL: Repository, https://github.com/agentculture/devex
@@ -44,6 +44,14 @@ devex overview --agent claude-code
44
44
  devex learn --agent claude-code
45
45
  ```
46
46
 
47
+ ## PR webhooks (opt-in)
48
+
49
+ Set `DEVEX_PR_WEBHOOK_URL` and `devex pr open` / `pr reply` / `pr review` will POST
50
+ a best-effort JSON notification (`{event, repo, id, subject, content}`) to that
51
+ webhook. Discord URLs are auto-detected and get a Discord-shaped payload; any other
52
+ URL gets the raw fields. Unset = no network call; a failed POST never blocks the PR
53
+ action. See `devex explain pr` for details.
54
+
47
55
  ## Docs
48
56
 
49
57
  [culture.dev/devex](https://culture.dev/devex/).
@@ -18,6 +18,14 @@ devex overview --agent claude-code
18
18
  devex learn --agent claude-code
19
19
  ```
20
20
 
21
+ ## PR webhooks (opt-in)
22
+
23
+ Set `DEVEX_PR_WEBHOOK_URL` and `devex pr open` / `pr reply` / `pr review` will POST
24
+ a best-effort JSON notification (`{event, repo, id, subject, content}`) to that
25
+ webhook. Discord URLs are auto-detected and get a Discord-shaped payload; any other
26
+ URL gets the raw fields. Unset = no network call; a failed POST never blocks the PR
27
+ action. See `devex explain pr` for details.
28
+
21
29
  ## Docs
22
30
 
23
31
  [culture.dev/devex](https://culture.dev/devex/).
@@ -21,8 +21,8 @@ This spec is also the test bed for relaxing agex's "no network / no sleep" invar
21
21
 
22
22
  The original agex invariants stand, with two scoped relaxations:
23
23
 
24
- - **Invariant 3 carve-out:** "no retries, no sleeps, no network" is relaxed for the `agex pr` namespace only, and only for: GitHub API reads/writes (via `gh` shellout in v0.1), and `--wait`-gated bounded sleep in `agex pr read`. **No silent retries anywhere** — if the agent wants retry, it reruns the command.
25
- - **Invariant 6 extension:** the enumerated side-effecting commands gain `pr open`, `pr reply`, and `pr read` (journal writes only). Network reads in `pr read` are not "side effects" by this definition; journal writes are.
24
+ - **Invariant 3 carve-out:** "no retries, no sleeps, no network" is relaxed for the `agex pr` namespace only, and only for: GitHub API reads/writes (via `gh` shellout in v0.1), `--wait`-gated bounded sleep in `agex pr read`, and — **added later (v0.29.0)** — a single opt-in outbound webhook POST on `pr open` / `pr reply` / `pr review`. This is the namespace's only **non-`gh`** egress (stdlib `urllib`, centralized in `core/webhook.py`); it is **off unless `DEVEX_PR_WEBHOOK_URL` (or a config-named env var) is set**, fail-open (a failed POST never aborts the verb), and bounded (5s timeout). **No silent retries anywhere** — if the agent wants retry, it reruns the command.
25
+ - **Invariant 6 extension:** the enumerated side-effecting commands gain `pr open`, `pr reply`, and `pr read` (journal writes only). Network reads in `pr read` are not "side effects" by this definition; journal writes are. The webhook POST (above) and its `pr_webhook_posted` / `pr_webhook_failed` journal events are likewise side effects of `pr open` / `pr reply` / `pr review` when the webhook is configured.
26
26
 
27
27
  Unchanged invariants:
28
28
 
@@ -38,11 +38,13 @@ Unchanged invariants:
38
38
  | Verb | Replaces (bash) | Side effects |
39
39
  |---|---|---|
40
40
  | `agex pr lint` | `portability-lint.sh` | Read-only. |
41
- | `agex pr open --title T [--body-file F] [--draft] [--delayed-read]` | first half of `create-pr-and-wait.sh` | `gh pr create`; one journal append. With `--delayed-read`, chains to `read --wait 180`. |
41
+ | `agex pr open --title T [--body-file F] [--draft] [--delayed-read]` | first half of `create-pr-and-wait.sh` | `gh pr create`; one journal append. With `--delayed-read`, chains to `read --wait 180`. + optional webhook POST (env-gated). |
42
42
  | `agex pr read [<PR>] [--wait SECS]` | `pr-status.sh` + `pr-comments.sh` + `poll-readiness.sh` | Read-only network; one journal append (or two with readiness). Bounded sleep when `--wait` given. |
43
- | `agex pr reply <PR>` | `pr-reply.sh` + `pr-batch.sh` | Posts comments, resolves threads; one journal append per reply, one per batch. |
43
+ | `agex pr reply <PR>` | `pr-reply.sh` + `pr-batch.sh` | Posts comments, resolves threads; one journal append per reply, one per batch. + optional webhook POST (env-gated). |
44
44
  | `agex pr delta` | `delta` subcommand of `workflow.sh` | Read-only. |
45
45
 
46
+ `agex pr review [<PR>]` (added after v0.1) posts the `/agentic_review` trigger comment + one journal append, plus the optional env-gated webhook POST.
47
+
46
48
  Every command ends with a deterministic **"Next step:"** footer derived from a small per-command rule table. Footers reference the next agex command in the typical chain (e.g. `pr lint` clean → "commit, push, then `agex pr open --title ...`"; `pr open` → "rerun `agex pr read <N> --wait 180` (recommended) or `agex pr read <N>` in ~3 min").
47
49
 
48
50
  Deliberately **not** in v0.1:
@@ -120,6 +122,21 @@ def resolve_nick(project_dir: Path) -> str: ... # ports _resolve-nick.sh
120
122
 
121
123
  Every call shells `gh api ...` / `gh pr ...` and parses JSON. `RuntimeError` with the gh stderr first line on hard failure; soft failures (missing SonarCloud project) return `None` / `[]` so renders still succeed. When httpx replaces gh, only this file changes.
122
124
 
125
+ ### New: `core/webhook.py` (v0.29.0) — generic transport
126
+
127
+ The single non-`gh` egress, centralized so the future zero-trust httpx swap touches `github.py` *and* this file only. **Generic** — it knows HTTP and Discord-*the-service*, but nothing about the `pr` namespace (no env var, no config keys, no PR payload fields), so `core/` stays command-agnostic. No `gh`, no journal, no filesystem writes:
128
+
129
+ ```python
130
+ class PostResult(str, Enum): DISABLED | POSTED | FAILED
131
+ def is_discord_url(url) -> bool # host in Discord set AND /api/webhooks/ path
132
+ def is_http_url(url) -> bool # http(s) scheme guard
133
+ def post(url: str | None, payload: dict) -> PostResult # stdlib urllib, 5s, no redirects, never raises
134
+ ```
135
+
136
+ `post()` returns `DISABLED` for a falsy URL (no network), validates the scheme is http(s) (SSRF/B310 guard), uses a **no-redirect opener** (a 3xx → `HTTPError` → `FAILED`, so a redirect can't bounce the POST past the validated URL), POSTs `json.dumps(...).encode("utf-8")` (non-ASCII-safe), treats only 2xx as `POSTED`, and swallows every failure (incl. a non-serialisable payload) to `FAILED`.
137
+
138
+ The devex-pr specifics live in `commands/pr/scripts/_webhook.py` (keeping core agnostic): the `DEVEX_PR_WEBHOOK_URL` env var (a blank/whitespace value counts as unset), `[pr.webhook]` config (`url_env`, `format`), the `{event,repo,id,subject,content}` payload, and the discord/generic selection. Its `notify()` is fully **fail-open**: it resolves config first (returning early when disabled — no `gh` lookup or network), and wraps `config.load()`, `github._repo_slug()`, `webhook.post()`, and the journal append so *any* error degrades to best-effort rather than aborting `pr open`/`reply`/`review`. It journals `pr_webhook_posted` / `pr_webhook_failed` (type + pr + event only; never the URL or payload).
139
+
123
140
  ### New: `core/journal.py`
124
141
 
125
142
  `append_event(stream, payload)` — JSONL append using `portalocker.lock` / `portalocker.unlock` (matches the existing convention in `core/hook_io.py`). `pr` events go to `.agex/data/pr/events.jsonl`. `core/hook_io.py` stays unchanged in v0.1 (a future refactor can collapse them); the new `pr` namespace uses `core/journal.py` directly to avoid touching the existing hook surface.
@@ -263,7 +280,7 @@ Event schema:
263
280
  "type": "pr_opened", "pr": 42, "title": "feat: ..."}
264
281
  ```
265
282
 
266
- Event types in v0.1: `pr_opened`, `pr_read`, `readiness_arrived`, `pr_reply`, `pr_batch_replied`. Future types (`pr_merged`, `pr_closed`) just append. `agex hook read` extends to discover `pr/` alongside `data/*.json`.
283
+ Event types in v0.1: `pr_opened`, `pr_read`, `readiness_arrived`, `pr_reply`, `pr_batch_replied`. Added in v0.29.0: `pr_webhook_posted`, `pr_webhook_failed` (each `{type, pr, event}` — no URL/payload). Future types (`pr_merged`, `pr_closed`) just append. `agex hook read` extends to discover `pr/` alongside `data/*.json`.
267
284
 
268
285
  ### `config.toml` additions
269
286
 
@@ -272,9 +289,15 @@ Event types in v0.1: `pr_opened`, `pr_read`, `readiness_arrived`, `pr_reply`, `p
272
289
  wait_default = 180
273
290
  required_reviewers = ["qodo"]
274
291
  gh_exec = "gh"
292
+
293
+ # Optional outbound webhook (v0.29.0). The secret URL is NEVER stored here —
294
+ # it is read only from the environment.
295
+ [pr.webhook]
296
+ url_env = "MY_DISCORD_WEBHOOK" # names the env var holding the URL; DEVEX_PR_WEBHOOK_URL wins
297
+ format = "auto" # auto (default) | discord | generic
275
298
  ```
276
299
 
277
- All optional.
300
+ All optional. With no `DEVEX_PR_WEBHOOK_URL` and no `[pr.webhook]`, the webhook is disabled and no network call is made.
278
301
 
279
302
  ## Error handling
280
303
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agex-cli"
3
- version = "0.28.0"
3
+ version = "0.29.0"
4
4
  description = "Agent-operated developer-experience CLI — deterministic per-backend markdown briefings for autonomous agents."
5
5
  authors = [{name = "Ori Nachum"}]
6
6
  license = {text = "MIT"}
@@ -87,6 +87,32 @@ no committed removal date), so devex never posts `/improve`.
87
87
  The command string lives in one place (`scripts/review.QODO_REVIEW_TRIGGER`), so
88
88
  a future Qodo rename is a one-line change for every consumer of `devex pr`.
89
89
 
90
+ ## Webhook notifications (opt-in)
91
+
92
+ `pr open`, `pr reply`, and `pr review` can POST a one-shot JSON notification to an
93
+ external webhook so something outside the repo learns the PR moved. It is **off by
94
+ default** — devex makes the call only when a webhook URL is configured, and a
95
+ failed POST never aborts the verb (the PR action has already succeeded).
96
+
97
+ The URL is a secret (a Discord webhook URL embeds a token), so it is read **only
98
+ from the environment**, never from `.devex/config.toml`:
99
+
100
+ - `DEVEX_PR_WEBHOOK_URL` — set this and you're done (zero config).
101
+ - or `[pr.webhook] url_env = "MY_VAR"` in `.devex/config.toml` to name a different
102
+ env var holding the secret. `DEVEX_PR_WEBHOOK_URL` always wins if both are set.
103
+
104
+ Payload (the same inputs used to act on the PR):
105
+
106
+ ```json
107
+ {"event": "pr_opened", "repo": "owner/repo", "id": 42,
108
+ "subject": "<PR title>", "content": "<PR body / summary>"}
109
+ ```
110
+
111
+ `event` is `pr_opened` / `pr_replied` / `pr_review_triggered`. **Discord** webhooks
112
+ are auto-detected by host and instead receive a `{content, embeds: [...]}` payload;
113
+ set `[pr.webhook] format = auto|discord|generic` to force the shape. `pr open` fires
114
+ only for a new, non-draft PR; `pr reply` only when at least one reply posted.
115
+
90
116
  ## SonarCloud project key
91
117
 
92
118
  `pr read` and `pr await` query the SonarCloud quality gate, new-code issues, and
@@ -119,9 +145,11 @@ Every command ends with a `**Next step:**` footer — chase the chain without gu
119
145
 
120
146
  Network: every command except `lint` and `delta` talks to GitHub via `gh`.
121
147
  `pr open` (non-draft, new PR) and `pr review` post the `/agentic_review` trigger
122
- comment.
148
+ comment. When `DEVEX_PR_WEBHOOK_URL` (or a config-named env var) is set, `pr open` /
149
+ `pr reply` / `pr review` also POST a best-effort notification to that webhook — the
150
+ only non-`gh` network call, and the only one that is off unless explicitly configured.
123
151
  Disk: `pr open`, `pr read`, `pr reply`, and `pr review` append events to
124
- `.devex/data/pr/events.jsonl`.
152
+ `.devex/data/pr/events.jsonl` (including `pr_webhook_posted` / `pr_webhook_failed`).
125
153
 
126
154
  ## Prerequisites
127
155
 
@@ -14,4 +14,9 @@ PR opened: **#{{ pr }}**{% if url %} ({{ url }}){% endif %}.
14
14
  {%- if review_failed %}
15
15
  > ⚠️ PR created, but posting `{{ review_command }}` failed (network/`gh`). The PR is open — run `{{ prog }} pr review {{ pr }}` to post the Qodo trigger once reachable.
16
16
  {% endif %}
17
+ {%- if webhook_posted %}- Notified webhook of the new PR.
18
+ {% endif %}
19
+ {%- if webhook_failed %}
20
+ > ⚠️ PR created, but the webhook notification failed (best-effort, skipped). The PR is open.
21
+ {% endif %}
17
22
  {{ footer }}
@@ -11,5 +11,9 @@
11
11
  | {{ f.line }} | {{ f.reason }} | `{{ f.entry }}` |
12
12
  {% endfor %}
13
13
  {%- endif %}
14
-
14
+ {% if webhook_posted %}- Notified webhook of the replies.
15
+ {% endif %}
16
+ {%- if webhook_failed %}
17
+ > ⚠️ Replies posted, but the webhook notification failed (best-effort, skipped).
18
+ {% endif %}
15
19
  {{ footer }}
@@ -0,0 +1,11 @@
1
+ # `{{ prog }} pr review`
2
+
3
+ Posted `{{ command }}` on PR **#{{ pr }}** to start a Qodo agentic review.
4
+ {% if webhook_posted %}
5
+ - Notified webhook of the review trigger.
6
+ {%- endif %}
7
+ {%- if webhook_failed %}
8
+ > ⚠️ Trigger posted, but the webhook notification failed (best-effort, skipped).
9
+ {%- endif %}
10
+
11
+ {{ footer }}
@@ -0,0 +1,143 @@
1
+ """Best-effort PR-webhook policy for the `pr` namespace.
2
+
3
+ Owns everything ``devex pr``-specific about the webhook — the
4
+ ``DEVEX_PR_WEBHOOK_URL`` env var, the ``[pr.webhook]`` config keys, the PR
5
+ payload shape, and the Discord/generic format selection — and delegates the
6
+ actual HTTP to the generic :mod:`devex.core.webhook` transport. Keeping these
7
+ specifics here (not in ``core/``) preserves core's command-agnostic rule.
8
+
9
+ :func:`notify` is strictly **fail-open**: it resolves config first (so a
10
+ disabled webhook does no network and no ``gh`` lookup) and swallows *every*
11
+ error — malformed config, a ``gh`` failure resolving the repo slug, a journal
12
+ write error — so a webhook problem can never abort ``pr open`` / ``reply`` /
13
+ ``review`` after their primary side effect already succeeded.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import Any
20
+
21
+ from devex.commands.pr.scripts import _journal
22
+ from devex.core import config as config_mod
23
+ from devex.core import github, webhook
24
+
25
+ # The secret env var. Lives here (not in core) because it is `devex pr`-specific.
26
+ ENV_WEBHOOK_URL = "DEVEX_PR_WEBHOOK_URL"
27
+
28
+ # Discord's content field caps at 2000 chars and an embed description at 4096;
29
+ # trim defensively below both so a large PR body never gets the POST rejected.
30
+ _MAX_CONTENT_CHARS = 1900
31
+ _MAX_TITLE_CHARS = 256
32
+
33
+ _VALID_FORMATS = ("auto", "discord", "generic")
34
+
35
+
36
+ def _resolve(config: Any) -> tuple[str | None, str]:
37
+ """Return ``(url, fmt)``; ``url`` is ``None`` when the webhook is disabled.
38
+
39
+ The secret URL is read **only** from the environment — ``DEVEX_PR_WEBHOOK_URL``
40
+ (wins), else the env var named by ``[pr.webhook].url_env``. A blank or
41
+ whitespace-only value counts as unset (and a blank primary falls through to
42
+ ``url_env``). ``fmt`` from ``[pr.webhook].format`` (``auto`` | ``discord`` |
43
+ ``generic``), defaulting to ``auto``. Defensive over the open-ended ``pr``
44
+ dict so a malformed table degrades to disabled.
45
+ """
46
+ try:
47
+ webhook_cfg = dict(config.pr.get("webhook", {}) or {})
48
+ except (AttributeError, TypeError):
49
+ webhook_cfg = {}
50
+
51
+ url = (os.environ.get(ENV_WEBHOOK_URL) or "").strip() or None
52
+ if url is None:
53
+ url_env = webhook_cfg.get("url_env")
54
+ if url_env:
55
+ url = (os.environ.get(str(url_env)) or "").strip() or None
56
+
57
+ fmt = str(webhook_cfg.get("format", "auto")).lower()
58
+ if fmt not in _VALID_FORMATS:
59
+ fmt = "auto"
60
+ return url, fmt
61
+
62
+
63
+ def _trim(text: str, limit: int) -> str:
64
+ return text if len(text) <= limit else text[: limit - 1] + "…"
65
+
66
+
67
+ def _build_payload(
68
+ fmt: str,
69
+ url: str,
70
+ *,
71
+ event: str,
72
+ repo: str,
73
+ pr_id: int,
74
+ subject: str,
75
+ content: str,
76
+ ) -> dict:
77
+ """Shape the PR notification.
78
+
79
+ Discord (``fmt == "discord"`` or ``auto`` + a Discord URL) gets a
80
+ ``{content, embeds:[...]}`` envelope; everything else gets the raw fields.
81
+ """
82
+ use_discord = fmt == "discord" or (fmt == "auto" and webhook.is_discord_url(url))
83
+ if use_discord:
84
+ return {
85
+ "content": _trim(f"{event} on {repo}: #{pr_id} — {subject}", _MAX_TITLE_CHARS),
86
+ "embeds": [
87
+ {
88
+ "title": _trim(subject, _MAX_TITLE_CHARS),
89
+ "description": _trim(content, _MAX_CONTENT_CHARS),
90
+ "fields": [
91
+ {"name": "repo", "value": repo, "inline": True},
92
+ {"name": "id", "value": str(pr_id), "inline": True},
93
+ {"name": "event", "value": event, "inline": True},
94
+ ],
95
+ }
96
+ ],
97
+ }
98
+ return {"event": event, "repo": repo, "id": pr_id, "subject": subject, "content": content}
99
+
100
+
101
+ def _journal_result(pr: int, event: str, result: webhook.PostResult) -> tuple[bool, bool]:
102
+ posted = result is webhook.PostResult.POSTED
103
+ failed = result is webhook.PostResult.FAILED
104
+ try:
105
+ if posted:
106
+ _journal.append({"type": "pr_webhook_posted", "pr": pr, "event": event})
107
+ elif failed:
108
+ _journal.append({"type": "pr_webhook_failed", "pr": pr, "event": event})
109
+ except Exception:
110
+ pass # nosec B110 - journal write must not break the fail-open contract
111
+ return posted, failed
112
+
113
+
114
+ def notify(*, event: str, pr: int, subject: str, content: str) -> tuple[bool, bool]:
115
+ """Notify the configured webhook of a PR event. Returns ``(posted, failed)``.
116
+
117
+ ``(False, False)`` means disabled — no network call and no ``gh`` repo-slug
118
+ lookup. Otherwise exactly one of ``posted`` / ``failed`` is True and a
119
+ matching journal event (``pr_webhook_posted`` / ``pr_webhook_failed``) is
120
+ appended (recording only ``{type, pr, event}`` — never the URL or payload).
121
+ **Never raises** — any error is swallowed so a webhook problem can't abort
122
+ the PR verb.
123
+ """
124
+ try:
125
+ url, fmt = _resolve(config_mod.load())
126
+ except Exception: # malformed config etc. -> disabled, never abort the verb
127
+ return False, False
128
+
129
+ if not url:
130
+ return False, False
131
+ if not webhook.is_http_url(url):
132
+ # Obviously-bad URL: surface as a failed attempt, but skip the gh round-trip.
133
+ return _journal_result(pr, event, webhook.PostResult.FAILED)
134
+
135
+ try:
136
+ repo = github._repo_slug() # noqa: SLF001 - established accessor (cf. _sonar.py)
137
+ payload = _build_payload(
138
+ fmt, url, event=event, repo=repo, pr_id=pr, subject=subject, content=content
139
+ )
140
+ result = webhook.post(url, payload)
141
+ except Exception: # gh failure / unexpected -> best-effort FAILED, never abort the verb
142
+ result = webhook.PostResult.FAILED
143
+ return _journal_result(pr, event, result)
@@ -7,7 +7,7 @@ from importlib.resources import files
7
7
  from pathlib import Path
8
8
 
9
9
  from devex.commands.pr.assets.rules.next_step_rules import open_next_step
10
- from devex.commands.pr.scripts import _journal, review
10
+ from devex.commands.pr.scripts import _journal, _webhook, review
11
11
  from devex.commands.pr.scripts._footer import render_footer
12
12
  from devex.core import github
13
13
  from devex.core.backend import resolve_backend
@@ -75,6 +75,17 @@ def run(
75
75
  except RuntimeError:
76
76
  review_failed = True
77
77
 
78
+ # Best-effort outbound webhook notify — same fail-open contract as the Qodo
79
+ # trigger above, gated identically (new, non-draft PR only). When no webhook
80
+ # URL is configured (DEVEX_PR_WEBHOOK_URL / [pr.webhook].url_env), this makes
81
+ # no network call and leaves both flags False. `body` is the signed body.
82
+ webhook_posted = False
83
+ webhook_failed = False
84
+ if not was_already_open and not draft:
85
+ webhook_posted, webhook_failed = _webhook.notify(
86
+ event="pr_opened", pr=pr, subject=title, content=body
87
+ )
88
+
78
89
  footer_key, footer_ctx = open_next_step(pr, was_already_open)
79
90
  footer = render_footer(footer_key, backend, footer_ctx)
80
91
 
@@ -91,6 +102,8 @@ def run(
91
102
  "review_posted": review_posted,
92
103
  "review_failed": review_failed,
93
104
  "review_command": review.QODO_REVIEW_TRIGGER,
105
+ "webhook_posted": webhook_posted,
106
+ "webhook_failed": webhook_failed,
94
107
  "footer": footer,
95
108
  },
96
109
  )
@@ -9,7 +9,7 @@ from importlib.resources import files
9
9
  from pathlib import Path
10
10
 
11
11
  from devex.commands.pr.assets.rules.next_step_rules import reply_next_step
12
- from devex.commands.pr.scripts import _journal
12
+ from devex.commands.pr.scripts import _journal, _webhook
13
13
  from devex.commands.pr.scripts._footer import render_footer
14
14
  from devex.core import github
15
15
  from devex.core.backend import resolve_backend
@@ -141,6 +141,19 @@ def run(
141
141
 
142
142
  _journal.append({"type": "pr_batch_replied", "pr": pr, "count": posted, "resolved": resolved})
143
143
 
144
+ # Best-effort webhook notify — only when at least one reply was posted (a
145
+ # no-op or all-failed batch shouldn't notify). `content` is a concise batch
146
+ # summary rather than every reply body. Disabled = no network call.
147
+ webhook_posted = False
148
+ webhook_failed = False
149
+ if posted:
150
+ webhook_posted, webhook_failed = _webhook.notify(
151
+ event="pr_replied",
152
+ pr=pr,
153
+ subject=f"Replied on PR #{pr}",
154
+ content=f"Posted {posted} comment(s); resolved {resolved} thread(s).",
155
+ )
156
+
144
157
  footer_key, footer_ctx = reply_next_step(pr=pr, failure_count=len(failures))
145
158
  footer = render_footer(footer_key, backend, footer_ctx)
146
159
 
@@ -152,6 +165,8 @@ def run(
152
165
  "count": posted,
153
166
  "resolved": resolved,
154
167
  "failures": [f.__dict__ for f in failures],
168
+ "webhook_posted": webhook_posted,
169
+ "webhook_failed": webhook_failed,
155
170
  "footer": footer,
156
171
  },
157
172
  )
@@ -14,7 +14,7 @@ from importlib.resources import files
14
14
  from pathlib import Path
15
15
 
16
16
  from devex.commands.pr.assets.rules.next_step_rules import review_next_step
17
- from devex.commands.pr.scripts import _journal
17
+ from devex.commands.pr.scripts import _journal, _webhook
18
18
  from devex.commands.pr.scripts._footer import render_footer
19
19
  from devex.core import github
20
20
  from devex.core.backend import resolve_backend
@@ -44,6 +44,16 @@ def run(agent: str | None, project_dir: Path, pr: int | None) -> tuple[str, int,
44
44
 
45
45
  post_trigger(pr_number)
46
46
 
47
+ # Best-effort webhook notify for the explicit `pr review` verb. Deliberately
48
+ # NOT inside post_trigger(): `pr open` calls post_trigger() for its auto-review
49
+ # and must emit only `pr_opened`, never a second `pr_review_triggered`.
50
+ webhook_posted, webhook_failed = _webhook.notify(
51
+ event="pr_review_triggered",
52
+ pr=pr_number,
53
+ subject=f"Review triggered on PR #{pr_number}",
54
+ content=QODO_REVIEW_TRIGGER,
55
+ )
56
+
47
57
  footer_key, footer_ctx = review_next_step(pr_number)
48
58
  footer = render_footer(footer_key, backend, footer_ctx)
49
59
 
@@ -53,6 +63,8 @@ def run(agent: str | None, project_dir: Path, pr: int | None) -> tuple[str, int,
53
63
  {
54
64
  "pr": pr_number,
55
65
  "command": QODO_REVIEW_TRIGGER,
66
+ "webhook_posted": webhook_posted,
67
+ "webhook_failed": webhook_failed,
56
68
  "footer": footer,
57
69
  },
58
70
  )