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.
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.github/workflows/test.yml +6 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/CHANGELOG.md +18 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/CLAUDE.md +12 -3
- {agex_cli-0.28.0 → agex_cli-0.29.0}/PKG-INFO +9 -1
- {agex_cli-0.28.0 → agex_cli-0.29.0}/README.md +8 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/specs/2026-05-10-agex-pr-design.md +29 -6
- {agex_cli-0.28.0 → agex_cli-0.29.0}/pyproject.toml +1 -1
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/SKILL.md +30 -2
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_open_result.md.j2 +5 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_reply_result.md.j2 +5 -1
- agex_cli-0.29.0/src/devex/commands/pr/assets/templates/pr_review_result.md.j2 +11 -0
- agex_cli-0.29.0/src/devex/commands/pr/scripts/_webhook.py +143 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/open_.py +14 -1
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/reply.py +16 -1
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/review.py +13 -1
- agex_cli-0.29.0/src/devex/core/webhook.py +122 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_open.py +108 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_reply.py +36 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_review.py +37 -0
- agex_cli-0.29.0/tests/commands/pr/test_webhook_helper.py +218 -0
- agex_cli-0.29.0/tests/conftest.py +61 -0
- agex_cli-0.29.0/tests/core/test_webhook.py +157 -0
- agex_cli-0.29.0/tests/core/test_webhook_live.py +48 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/uv.lock +1 -1
- agex_cli-0.28.0/src/devex/commands/pr/assets/templates/pr_review_result.md.j2 +0 -5
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/agent-config/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/cicd/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/run-tests/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/think/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/think/scripts/think.sh +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/version-bump/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/frames/devex-now-turns-a-push-into-continuous-pr-manageme.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/frames/every-devex-command-now-closes-with-a-deterministi.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/plans/devex-now-turns-a-push-into-continuous-pr-manageme.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.devague/plans/every-devex-command-now-closes-with-a-deterministi.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.flake8 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.github/workflows/publish.yml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.gitignore +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/.python-version +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/LICENSE +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/culture.yaml +0 -0
- {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
- {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
- {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/skill-sources.md +0 -0
- {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
- {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
- {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/plans/2026-04-18-agex-v0.1.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/plans/2026-05-10-agex-pr.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/specs/2026-04-18-agex-design.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/docs/superpowers/specs/2026-04-26-agex-doctor.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/sonar-project.properties +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/__main__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/acp/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/acp/probe.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/capabilities/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/claude_code/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/claude_code/probe.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/codex/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/codex/probe.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/copilot/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/backends/copilot/probe.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/cli.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/assets/report.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/references/design.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/_footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/doctor.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/doctor/scripts/next_step.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/assets/topics/devex.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/references/.gitkeep +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/_footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/explain.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/explain/scripts/next_step.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/assets/hooks/claude-code.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/references/.gitkeep +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/scripts/install.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/gamify/scripts/next_step.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/assets/table.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/references/.gitkeep +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/_footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/next_step.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/read.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/hook/scripts/write.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/menu.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/cicd/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/gamify/SKILL.md +0 -0
- {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
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/introspect/SKILL.md +0 -0
- {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
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/levelup/SKILL.md +0 -0
- {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
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/assets/topics/visualize/SKILL.md +0 -0
- {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
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/references/.gitkeep +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/scripts/learn.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/learn/scripts/next_step.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/assets/sections.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/references/.gitkeep +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/_footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/next_step.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/overview/scripts/overview.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/rules/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/rules/lint_rules.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/rules/next_step_rules.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/delta.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/lint_result.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_await_detached.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_briefing.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_await_worker.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_deploy.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_detach.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_journal.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_qodo.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_readiness.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/_sonar.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/await_.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/delta.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/lint.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/scripts/read.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/acp.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/claude-code.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/codex.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/assets/backends/copilot.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/scripts/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/push/scripts/push.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/_jsonl.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/backends/neutral.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/assets/footer.md.j2 +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/backend.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/capabilities.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/config.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/github.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/hook_io.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/journal.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/paths.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/prog.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/render.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/core/skill_loader.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/.claude/settings.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/.claude/skills +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/CLAUDE.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/README.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tester-agents/claude/culture.yaml +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/backends/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/backends/test_claude_code_probe.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/backends/test_stub_probes.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/.gitkeep +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/pr_checks_42.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/pr_comments_42.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/gh/qodo_summary_comment.html +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/fixtures/journals/dogfood_40.jsonl +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_await.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_await_detach.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_delta.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_deploy.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_lint.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_lint_rules.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_qodo.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_read.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/pr/test_readiness.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/push/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/push/test_push.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/push/test_push_backends.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_doctor.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_explain.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_gamify.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_hook.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_learn.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_overview.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/commands/test_prog_propagation.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/__init__.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_backend.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_capabilities.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_config.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_footer.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_github.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_hook_io.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_journal.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_paths.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_prog.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_render.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_resolve_backend.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_skill_loader.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/core/test_version_lookup.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/empty/.gitkeep +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/hooks.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/settings.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/skills/bad/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/malformed/.claude/skills/broken-yaml/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/.claude/hooks.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/.claude/settings.json +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/.claude/skills/example/SKILL.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/fixtures/claude-code/typical/CLAUDE.md +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_cli_dispatch.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_cli_errors.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_cli_smoke.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_footer_guarantee.py +0 -0
- {agex_cli-0.28.0 → agex_cli-0.29.0}/tests/test_footer_hints.py +0 -0
- {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.
|
|
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.
|
|
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),
|
|
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
|
|
|
@@ -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
|
|
{agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_open_result.md.j2
RENAMED
|
@@ -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 }}
|
{agex_cli-0.28.0 → agex_cli-0.29.0}/src/devex/commands/pr/assets/templates/pr_reply_result.md.j2
RENAMED
|
@@ -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
|
)
|