@humanbased/crosscheck 1.2.0 → 1.3.0-beta.100
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.
- package/LICENSE +1 -1
- package/README.md +183 -375
- package/README.zh.md +1 -1
- package/assets/icon-256.png +0 -0
- package/assets/linear-comment.svg +18 -0
- package/assets/linear-onboard.svg +30 -0
- package/assets/linear-status.svg +23 -0
- package/assets/linear-test.svg +34 -0
- package/assets/skills/code-review/.crosscheck-skill.json +9 -0
- package/assets/skills/code-review/LICENSE +21 -0
- package/assets/skills/code-review/SKILL.md +89 -0
- package/assets/skills/code-review/agents/openai.yaml +3 -0
- package/assets/skills/code-review-skill/.crosscheck-skill.json +9 -0
- package/assets/skills/code-review-skill/LICENSE +21 -0
- package/assets/skills/code-review-skill/SKILL.md +231 -0
- package/assets/skills/code-review-skill/assets/pr-review-template.md +137 -0
- package/assets/skills/code-review-skill/assets/review-checklist.md +123 -0
- package/assets/skills/code-review-skill/reference/angular.md +768 -0
- package/assets/skills/code-review-skill/reference/architecture-review-guide.md +472 -0
- package/assets/skills/code-review-skill/reference/c.md +890 -0
- package/assets/skills/code-review-skill/reference/code-quality-universal.md +488 -0
- package/assets/skills/code-review-skill/reference/code-review-best-practices.md +136 -0
- package/assets/skills/code-review-skill/reference/common-bugs-checklist.md +286 -0
- package/assets/skills/code-review-skill/reference/cpp.md +893 -0
- package/assets/skills/code-review-skill/reference/cross-cutting/async-concurrency-patterns.md +515 -0
- package/assets/skills/code-review-skill/reference/cross-cutting/error-handling-principles.md +492 -0
- package/assets/skills/code-review-skill/reference/cross-cutting/n-plus-one-queries.md +309 -0
- package/assets/skills/code-review-skill/reference/cross-cutting/sql-injection-prevention.md +308 -0
- package/assets/skills/code-review-skill/reference/cross-cutting/xss-prevention.md +264 -0
- package/assets/skills/code-review-skill/reference/csharp.md +525 -0
- package/assets/skills/code-review-skill/reference/css-less-sass.md +661 -0
- package/assets/skills/code-review-skill/reference/django.md +985 -0
- package/assets/skills/code-review-skill/reference/fastapi.md +580 -0
- package/assets/skills/code-review-skill/reference/go.md +993 -0
- package/assets/skills/code-review-skill/reference/java.md +409 -0
- package/assets/skills/code-review-skill/reference/java8.md +586 -0
- package/assets/skills/code-review-skill/reference/kotlin.md +1018 -0
- package/assets/skills/code-review-skill/reference/nestjs.md +593 -0
- package/assets/skills/code-review-skill/reference/performance-review-guide.md +816 -0
- package/assets/skills/code-review-skill/reference/php.md +684 -0
- package/assets/skills/code-review-skill/reference/python.md +1073 -0
- package/assets/skills/code-review-skill/reference/qt.md +757 -0
- package/assets/skills/code-review-skill/reference/react.md +871 -0
- package/assets/skills/code-review-skill/reference/ruby.md +964 -0
- package/assets/skills/code-review-skill/reference/rust.md +846 -0
- package/assets/skills/code-review-skill/reference/security-review-guide.md +494 -0
- package/assets/skills/code-review-skill/reference/svelte.md +1064 -0
- package/assets/skills/code-review-skill/reference/swift.md +936 -0
- package/assets/skills/code-review-skill/reference/typescript.md +1016 -0
- package/assets/skills/code-review-skill/reference/vue.md +924 -0
- package/assets/skills/code-review-skill/reference/zig.md +440 -0
- package/assets/skills/code-review-skill/scripts/pr-analyzer.py +435 -0
- package/assets/skills/code-review-skill/scripts/test_pr_analyzer.py +380 -0
- package/assets/skills/codebase-design/.crosscheck-skill.json +9 -0
- package/assets/skills/codebase-design/DEEPENING.md +37 -0
- package/assets/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/assets/skills/codebase-design/LICENSE +21 -0
- package/assets/skills/codebase-design/SKILL.md +114 -0
- package/assets/skills/codebase-design/agents/openai.yaml +3 -0
- package/assets/skills/diagnosing-bugs/.crosscheck-skill.json +9 -0
- package/assets/skills/diagnosing-bugs/LICENSE +21 -0
- package/assets/skills/diagnosing-bugs/SKILL.md +134 -0
- package/assets/skills/diagnosing-bugs/agents/openai.yaml +3 -0
- package/assets/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
- package/crosscheck.config.example.yml +124 -11
- package/dist/__tests__/adoption.test.d.ts +2 -0
- package/dist/__tests__/adoption.test.d.ts.map +1 -0
- package/dist/__tests__/adoption.test.js +175 -0
- package/dist/__tests__/adoption.test.js.map +1 -0
- package/dist/__tests__/auto-fix-branch.test.d.ts +2 -0
- package/dist/__tests__/auto-fix-branch.test.d.ts.map +1 -0
- package/dist/__tests__/auto-fix-branch.test.js +143 -0
- package/dist/__tests__/auto-fix-branch.test.js.map +1 -0
- package/dist/__tests__/board.test.js +13 -0
- package/dist/__tests__/board.test.js.map +1 -1
- package/dist/__tests__/can-write-verdict.test.d.ts +2 -0
- package/dist/__tests__/can-write-verdict.test.d.ts.map +1 -0
- package/dist/__tests__/can-write-verdict.test.js +31 -0
- package/dist/__tests__/can-write-verdict.test.js.map +1 -0
- package/dist/__tests__/codex-env.test.d.ts +2 -0
- package/dist/__tests__/codex-env.test.d.ts.map +1 -0
- package/dist/__tests__/codex-env.test.js +70 -0
- package/dist/__tests__/codex-env.test.js.map +1 -0
- package/dist/__tests__/codex.test.js +100 -25
- package/dist/__tests__/codex.test.js.map +1 -1
- package/dist/__tests__/comment-bodies.test.js +49 -1
- package/dist/__tests__/comment-bodies.test.js.map +1 -1
- package/dist/__tests__/commit-subject.test.d.ts +2 -0
- package/dist/__tests__/commit-subject.test.d.ts.map +1 -0
- package/dist/__tests__/commit-subject.test.js +72 -0
- package/dist/__tests__/commit-subject.test.js.map +1 -0
- package/dist/__tests__/conflict-resolve.test.js +44 -1
- package/dist/__tests__/conflict-resolve.test.js.map +1 -1
- package/dist/__tests__/credential-free-origin.test.d.ts +2 -0
- package/dist/__tests__/credential-free-origin.test.d.ts.map +1 -0
- package/dist/__tests__/credential-free-origin.test.js +59 -0
- package/dist/__tests__/credential-free-origin.test.js.map +1 -0
- package/dist/__tests__/detector.test.js +87 -1
- package/dist/__tests__/detector.test.js.map +1 -1
- package/dist/__tests__/error-classification.test.js +34 -0
- package/dist/__tests__/error-classification.test.js.map +1 -1
- package/dist/__tests__/fix.test.js +69 -0
- package/dist/__tests__/fix.test.js.map +1 -1
- package/dist/__tests__/harness-section.test.d.ts +2 -0
- package/dist/__tests__/harness-section.test.d.ts.map +1 -0
- package/dist/__tests__/harness-section.test.js +46 -0
- package/dist/__tests__/harness-section.test.js.map +1 -0
- package/dist/__tests__/kickass.test.js +31 -0
- package/dist/__tests__/kickass.test.js.map +1 -1
- package/dist/__tests__/linear-branding.test.d.ts +2 -0
- package/dist/__tests__/linear-branding.test.d.ts.map +1 -0
- package/dist/__tests__/linear-branding.test.js +156 -0
- package/dist/__tests__/linear-branding.test.js.map +1 -0
- package/dist/__tests__/linear-client.test.d.ts +2 -0
- package/dist/__tests__/linear-client.test.d.ts.map +1 -0
- package/dist/__tests__/linear-client.test.js +120 -0
- package/dist/__tests__/linear-client.test.js.map +1 -0
- package/dist/__tests__/linear-comment.test.d.ts +2 -0
- package/dist/__tests__/linear-comment.test.d.ts.map +1 -0
- package/dist/__tests__/linear-comment.test.js +151 -0
- package/dist/__tests__/linear-comment.test.js.map +1 -0
- package/dist/__tests__/linear-identity.test.d.ts +2 -0
- package/dist/__tests__/linear-identity.test.d.ts.map +1 -0
- package/dist/__tests__/linear-identity.test.js +253 -0
- package/dist/__tests__/linear-identity.test.js.map +1 -0
- package/dist/__tests__/linear-notify.test.d.ts +2 -0
- package/dist/__tests__/linear-notify.test.d.ts.map +1 -0
- package/dist/__tests__/linear-notify.test.js +144 -0
- package/dist/__tests__/linear-notify.test.js.map +1 -0
- package/dist/__tests__/linear-ref.test.d.ts +2 -0
- package/dist/__tests__/linear-ref.test.d.ts.map +1 -0
- package/dist/__tests__/linear-ref.test.js +261 -0
- package/dist/__tests__/linear-ref.test.js.map +1 -0
- package/dist/__tests__/linear-test-ref.test.d.ts +2 -0
- package/dist/__tests__/linear-test-ref.test.d.ts.map +1 -0
- package/dist/__tests__/linear-test-ref.test.js +81 -0
- package/dist/__tests__/linear-test-ref.test.js.map +1 -0
- package/dist/__tests__/linear-verify.test.d.ts +2 -0
- package/dist/__tests__/linear-verify.test.d.ts.map +1 -0
- package/dist/__tests__/linear-verify.test.js +132 -0
- package/dist/__tests__/linear-verify.test.js.map +1 -0
- package/dist/__tests__/linear-worker.test.d.ts +2 -0
- package/dist/__tests__/linear-worker.test.d.ts.map +1 -0
- package/dist/__tests__/linear-worker.test.js +83 -0
- package/dist/__tests__/linear-worker.test.js.map +1 -0
- package/dist/__tests__/linear-write-possible.test.d.ts +2 -0
- package/dist/__tests__/linear-write-possible.test.d.ts.map +1 -0
- package/dist/__tests__/linear-write-possible.test.js +30 -0
- package/dist/__tests__/linear-write-possible.test.js.map +1 -0
- package/dist/__tests__/loader.test.js +2 -2
- package/dist/__tests__/loader.test.js.map +1 -1
- package/dist/__tests__/onboard-preservation.test.js +59 -4
- package/dist/__tests__/onboard-preservation.test.js.map +1 -1
- package/dist/__tests__/optimize.test.js +2 -0
- package/dist/__tests__/optimize.test.js.map +1 -1
- package/dist/__tests__/pr-status.test.js +213 -3
- package/dist/__tests__/pr-status.test.js.map +1 -1
- package/dist/__tests__/pr-workflow-state.test.js +247 -7
- package/dist/__tests__/pr-workflow-state.test.js.map +1 -1
- package/dist/__tests__/repo-picker.test.js +7 -1
- package/dist/__tests__/repo-picker.test.js.map +1 -1
- package/dist/__tests__/repository-guidance.test.d.ts +2 -0
- package/dist/__tests__/repository-guidance.test.d.ts.map +1 -0
- package/dist/__tests__/repository-guidance.test.js +107 -0
- package/dist/__tests__/repository-guidance.test.js.map +1 -0
- package/dist/__tests__/review-comment-body.test.js +35 -0
- package/dist/__tests__/review-comment-body.test.js.map +1 -1
- package/dist/__tests__/review-models.test.js +19 -3
- package/dist/__tests__/review-models.test.js.map +1 -1
- package/dist/__tests__/review-strategy.test.d.ts +2 -0
- package/dist/__tests__/review-strategy.test.d.ts.map +1 -0
- package/dist/__tests__/review-strategy.test.js +397 -0
- package/dist/__tests__/review-strategy.test.js.map +1 -0
- package/dist/__tests__/reviewed-sha.test.d.ts +2 -0
- package/dist/__tests__/reviewed-sha.test.d.ts.map +1 -0
- package/dist/__tests__/reviewed-sha.test.js +116 -0
- package/dist/__tests__/reviewed-sha.test.js.map +1 -0
- package/dist/__tests__/runner.test.js +206 -2
- package/dist/__tests__/runner.test.js.map +1 -1
- package/dist/__tests__/skill-attribution.test.d.ts +2 -0
- package/dist/__tests__/skill-attribution.test.d.ts.map +1 -0
- package/dist/__tests__/skill-attribution.test.js +53 -0
- package/dist/__tests__/skill-attribution.test.js.map +1 -0
- package/dist/__tests__/skill-broker.test.d.ts +2 -0
- package/dist/__tests__/skill-broker.test.d.ts.map +1 -0
- package/dist/__tests__/skill-broker.test.js +186 -0
- package/dist/__tests__/skill-broker.test.js.map +1 -0
- package/dist/__tests__/skill-catalog.test.d.ts +2 -0
- package/dist/__tests__/skill-catalog.test.d.ts.map +1 -0
- package/dist/__tests__/skill-catalog.test.js +40 -0
- package/dist/__tests__/skill-catalog.test.js.map +1 -0
- package/dist/__tests__/skill-installer.test.d.ts +2 -0
- package/dist/__tests__/skill-installer.test.d.ts.map +1 -0
- package/dist/__tests__/skill-installer.test.js +96 -0
- package/dist/__tests__/skill-installer.test.js.map +1 -0
- package/dist/__tests__/skills-config.test.d.ts +2 -0
- package/dist/__tests__/skills-config.test.d.ts.map +1 -0
- package/dist/__tests__/skills-config.test.js +12 -0
- package/dist/__tests__/skills-config.test.js.map +1 -0
- package/dist/__tests__/superseded-fix-pr.test.d.ts +2 -0
- package/dist/__tests__/superseded-fix-pr.test.d.ts.map +1 -0
- package/dist/__tests__/superseded-fix-pr.test.js +225 -0
- package/dist/__tests__/superseded-fix-pr.test.js.map +1 -0
- package/dist/__tests__/webhook.test.js +60 -0
- package/dist/__tests__/webhook.test.js.map +1 -1
- package/dist/cli.js +37 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adoption.d.ts +6 -0
- package/dist/commands/adoption.d.ts.map +1 -0
- package/dist/commands/adoption.js +132 -0
- package/dist/commands/adoption.js.map +1 -0
- package/dist/commands/detect-step.d.ts.map +1 -1
- package/dist/commands/detect-step.js +5 -1
- package/dist/commands/detect-step.js.map +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +2 -2
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +1 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/issue.js +1 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/kickass.d.ts +1 -1
- package/dist/commands/kickass.d.ts.map +1 -1
- package/dist/commands/kickass.js +36 -22
- package/dist/commands/kickass.js.map +1 -1
- package/dist/commands/linear-test.d.ts +18 -0
- package/dist/commands/linear-test.d.ts.map +1 -0
- package/dist/commands/linear-test.js +130 -0
- package/dist/commands/linear-test.js.map +1 -0
- package/dist/commands/onboard.d.ts +36 -3
- package/dist/commands/onboard.d.ts.map +1 -1
- package/dist/commands/onboard.js +263 -45
- package/dist/commands/onboard.js.map +1 -1
- package/dist/commands/optimize.d.ts.map +1 -1
- package/dist/commands/optimize.js +1 -0
- package/dist/commands/optimize.js.map +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +65 -6
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +85 -11
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/scan.js +6 -0
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/skill.d.ts +2 -0
- package/dist/commands/skill.d.ts.map +1 -0
- package/dist/commands/skill.js +16 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +53 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +215 -66
- package/dist/commands/watch.js.map +1 -1
- package/dist/config/loader.d.ts +3 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +19 -6
- package/dist/config/loader.js.map +1 -1
- package/dist/config/review-model-tiers.json +3 -3
- package/dist/config/review-strategy.json +204 -0
- package/dist/config/schema.d.ts +269 -15
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +109 -10
- package/dist/config/schema.js.map +1 -1
- package/dist/github/client.d.ts +21 -1
- package/dist/github/client.d.ts.map +1 -1
- package/dist/github/client.js +46 -7
- package/dist/github/client.js.map +1 -1
- package/dist/github/detector.d.ts.map +1 -1
- package/dist/github/detector.js +28 -1
- package/dist/github/detector.js.map +1 -1
- package/dist/github/merge.d.ts +0 -5
- package/dist/github/merge.d.ts.map +1 -1
- package/dist/github/merge.js +0 -9
- package/dist/github/merge.js.map +1 -1
- package/dist/github/reviewed-sha.d.ts +13 -0
- package/dist/github/reviewed-sha.d.ts.map +1 -0
- package/dist/github/reviewed-sha.js +62 -0
- package/dist/github/reviewed-sha.js.map +1 -0
- package/dist/github/superseded-fix-pr.d.ts +19 -0
- package/dist/github/superseded-fix-pr.d.ts.map +1 -0
- package/dist/github/superseded-fix-pr.js +97 -0
- package/dist/github/superseded-fix-pr.js.map +1 -0
- package/dist/github/webhook.d.ts +8 -1
- package/dist/github/webhook.d.ts.map +1 -1
- package/dist/github/webhook.js +8 -1
- package/dist/github/webhook.js.map +1 -1
- package/dist/issues/ticket-ref.d.ts.map +1 -1
- package/dist/issues/ticket-ref.js +6 -5
- package/dist/issues/ticket-ref.js.map +1 -1
- package/dist/lib/adoption.d.ts +64 -0
- package/dist/lib/adoption.d.ts.map +1 -0
- package/dist/lib/adoption.js +165 -0
- package/dist/lib/adoption.js.map +1 -0
- package/dist/lib/annotation.d.ts +7 -0
- package/dist/lib/annotation.d.ts.map +1 -1
- package/dist/lib/annotation.js +11 -8
- package/dist/lib/annotation.js.map +1 -1
- package/dist/lib/auto-fix-branch.d.ts +35 -0
- package/dist/lib/auto-fix-branch.d.ts.map +1 -0
- package/dist/lib/auto-fix-branch.js +88 -0
- package/dist/lib/auto-fix-branch.js.map +1 -0
- package/dist/lib/board.d.ts +3 -0
- package/dist/lib/board.d.ts.map +1 -1
- package/dist/lib/board.js +4 -2
- package/dist/lib/board.js.map +1 -1
- package/dist/lib/clone.d.ts +2 -0
- package/dist/lib/clone.d.ts.map +1 -1
- package/dist/lib/clone.js +69 -10
- package/dist/lib/clone.js.map +1 -1
- package/dist/lib/comment-bodies.d.ts +37 -0
- package/dist/lib/comment-bodies.d.ts.map +1 -1
- package/dist/lib/comment-bodies.js +50 -9
- package/dist/lib/comment-bodies.js.map +1 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +17 -1
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/pr-picker.d.ts +1 -1
- package/dist/lib/pr-picker.d.ts.map +1 -1
- package/dist/lib/pr-picker.js +10 -5
- package/dist/lib/pr-picker.js.map +1 -1
- package/dist/lib/pr-status.d.ts +2 -1
- package/dist/lib/pr-status.d.ts.map +1 -1
- package/dist/lib/pr-status.js +70 -6
- package/dist/lib/pr-status.js.map +1 -1
- package/dist/lib/pr-workflow-state.d.ts +17 -1
- package/dist/lib/pr-workflow-state.d.ts.map +1 -1
- package/dist/lib/pr-workflow-state.js +110 -5
- package/dist/lib/pr-workflow-state.js.map +1 -1
- package/dist/lib/repo-picker.d.ts +3 -0
- package/dist/lib/repo-picker.d.ts.map +1 -1
- package/dist/lib/repo-picker.js +45 -10
- package/dist/lib/repo-picker.js.map +1 -1
- package/dist/lib/repository-guidance.d.ts +2 -0
- package/dist/lib/repository-guidance.d.ts.map +1 -0
- package/dist/lib/repository-guidance.js +55 -0
- package/dist/lib/repository-guidance.js.map +1 -0
- package/dist/lib/review-models.d.ts +15 -2
- package/dist/lib/review-models.d.ts.map +1 -1
- package/dist/lib/review-models.js +26 -6
- package/dist/lib/review-models.js.map +1 -1
- package/dist/lib/review-strategy.d.ts +92 -0
- package/dist/lib/review-strategy.d.ts.map +1 -0
- package/dist/lib/review-strategy.js +282 -0
- package/dist/lib/review-strategy.js.map +1 -0
- package/dist/lib/runner.d.ts +123 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +919 -101
- package/dist/lib/runner.js.map +1 -1
- package/dist/lib/vendor.d.ts +1 -0
- package/dist/lib/vendor.d.ts.map +1 -1
- package/dist/lib/vendor.js +6 -0
- package/dist/lib/vendor.js.map +1 -1
- package/dist/lib/workflow.d.ts +13 -0
- package/dist/lib/workflow.d.ts.map +1 -1
- package/dist/lib/workflow.js +24 -1
- package/dist/lib/workflow.js.map +1 -1
- package/dist/linear/client.d.ts +18 -0
- package/dist/linear/client.d.ts.map +1 -0
- package/dist/linear/client.js +67 -0
- package/dist/linear/client.js.map +1 -0
- package/dist/linear/comment.d.ts +20 -0
- package/dist/linear/comment.d.ts.map +1 -0
- package/dist/linear/comment.js +57 -0
- package/dist/linear/comment.js.map +1 -0
- package/dist/linear/identity.d.ts +59 -0
- package/dist/linear/identity.d.ts.map +1 -0
- package/dist/linear/identity.js +187 -0
- package/dist/linear/identity.js.map +1 -0
- package/dist/linear/notify.d.ts +35 -0
- package/dist/linear/notify.d.ts.map +1 -0
- package/dist/linear/notify.js +76 -0
- package/dist/linear/notify.js.map +1 -0
- package/dist/linear/ref.d.ts +13 -0
- package/dist/linear/ref.d.ts.map +1 -0
- package/dist/linear/ref.js +90 -0
- package/dist/linear/ref.js.map +1 -0
- package/dist/linear/verify.d.ts +26 -0
- package/dist/linear/verify.d.ts.map +1 -0
- package/dist/linear/verify.js +67 -0
- package/dist/linear/verify.js.map +1 -0
- package/dist/reviewers/address.d.ts +1 -1
- package/dist/reviewers/address.d.ts.map +1 -1
- package/dist/reviewers/address.js +1 -1
- package/dist/reviewers/address.js.map +1 -1
- package/dist/reviewers/claude.d.ts +4 -1
- package/dist/reviewers/claude.d.ts.map +1 -1
- package/dist/reviewers/claude.js +39 -7
- package/dist/reviewers/claude.js.map +1 -1
- package/dist/reviewers/codex-env.d.ts +12 -0
- package/dist/reviewers/codex-env.d.ts.map +1 -0
- package/dist/reviewers/codex-env.js +54 -0
- package/dist/reviewers/codex-env.js.map +1 -0
- package/dist/reviewers/codex.d.ts +18 -1
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +164 -81
- package/dist/reviewers/codex.js.map +1 -1
- package/dist/reviewers/conflict-resolve.d.ts +3 -1
- package/dist/reviewers/conflict-resolve.d.ts.map +1 -1
- package/dist/reviewers/conflict-resolve.js +21 -6
- package/dist/reviewers/conflict-resolve.js.map +1 -1
- package/dist/reviewers/fix.d.ts +5 -2
- package/dist/reviewers/fix.d.ts.map +1 -1
- package/dist/reviewers/fix.js +38 -12
- package/dist/reviewers/fix.js.map +1 -1
- package/dist/skills/attribution.d.ts +4 -0
- package/dist/skills/attribution.d.ts.map +1 -0
- package/dist/skills/attribution.js +14 -0
- package/dist/skills/attribution.js.map +1 -0
- package/dist/skills/broker-server.d.ts +2 -0
- package/dist/skills/broker-server.d.ts.map +1 -0
- package/dist/skills/broker-server.js +17 -0
- package/dist/skills/broker-server.js.map +1 -0
- package/dist/skills/broker.d.ts +43 -0
- package/dist/skills/broker.d.ts.map +1 -0
- package/dist/skills/broker.js +311 -0
- package/dist/skills/broker.js.map +1 -0
- package/dist/skills/catalog.d.ts +28 -0
- package/dist/skills/catalog.d.ts.map +1 -0
- package/dist/skills/catalog.js +104 -0
- package/dist/skills/catalog.js.map +1 -0
- package/dist/skills/installer.d.ts +10 -0
- package/dist/skills/installer.d.ts.map +1 -0
- package/dist/skills/installer.js +138 -0
- package/dist/skills/installer.js.map +1 -0
- package/dist/skills/integrity.d.ts +4 -0
- package/dist/skills/integrity.d.ts.map +1 -0
- package/dist/skills/integrity.js +36 -0
- package/dist/skills/integrity.js.map +1 -0
- package/docs/dynamic-thoroughness.md +738 -0
- package/docs/linear-identity-contract.md +139 -0
- package/docs/linear-identity.md +293 -0
- package/docs/metrics.md +115 -0
- package/docs/trust.md +159 -0
- package/get-started.md +322 -16
- package/package.json +6 -3
package/dist/lib/runner.js
CHANGED
|
@@ -3,25 +3,39 @@ import { randomUUID } from 'crypto';
|
|
|
3
3
|
import { readFileSync } from 'fs';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
+
import { filterStepsByTypes } from './repo-workflow.js';
|
|
7
|
+
import { vendorDisplayName } from '../lib/vendor.js';
|
|
6
8
|
import { runCodexReview } from '../reviewers/codex.js';
|
|
7
9
|
import { runClaudeReview } from '../reviewers/claude.js';
|
|
8
10
|
import { runFixStep, runCodexFixStep } from '../reviewers/fix.js';
|
|
9
11
|
import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
|
|
10
12
|
import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING, applySeverityGate, SEVERITY_GATE_NOTE } from '../lib/verdict.js';
|
|
11
13
|
import { createGithubClient, postReviewComment, getLastCrossCheckCommentId, getLastCrossCheckReviewComment } from '../github/client.js';
|
|
14
|
+
import { autoFixBranchName, autoFixPRIntro, sourcePRHasMerged } from '../github/superseded-fix-pr.js';
|
|
15
|
+
import { verifyReviewedSha, isVerifiedReviewedSha, reviewedShaRejection } from '../github/reviewed-sha.js';
|
|
16
|
+
import { resolveLinearAuth, withWorker } from '../linear/identity.js';
|
|
17
|
+
import { notifyLinear } from '../linear/notify.js';
|
|
18
|
+
import { shouldPostToLinear } from '../linear/comment.js';
|
|
19
|
+
import { getLinearCredentials } from '../config/loader.js';
|
|
12
20
|
import { acquireRemoteLock, releaseRemoteLock } from '../github/review-status.js';
|
|
13
21
|
import { log as fileLog, logError, classifyError } from '../lib/logger.js';
|
|
14
22
|
import { buildCommitTrailers } from '../lib/annotation.js';
|
|
15
23
|
import { resolveClaudeModel, resolveCodexModel } from '../lib/review-models.js';
|
|
24
|
+
import { resolveReviewStrategy, escalate, clampToLevels } from './review-strategy.js';
|
|
25
|
+
import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS } from '../config/schema.js';
|
|
16
26
|
import { buildStepIdentityFields } from '../lib/event-fields.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
27
|
+
import { planAutoFixDelivery, forceWithLeaseArgs, parseLsRemoteOid, isLeaseRejection, assessFixBranchOwnership, isInvalidBaseError } from '../lib/auto-fix-branch.js';
|
|
28
|
+
import { prOpenToVerdictMs } from '../lib/adoption.js';
|
|
29
|
+
import { buildAttributionFooter, buildFixAppliedCommentBody, buildFixFailedCommentBody, buildConflictResolvedCommentBody, buildRetriedReviewBanner } from '../lib/comment-bodies.js';
|
|
30
|
+
import { linearWritePossible, loadWorkflow, loadHarnessSection, evaluateWhen } from '../lib/workflow.js';
|
|
19
31
|
import { isSubscriptionLimitError, isVendorUnavailableError } from '../lib/smart-switch.js';
|
|
20
32
|
import { tierTimeoutMs } from '../reviewers/tier-timeouts.js';
|
|
33
|
+
import { loadSkillCatalog } from '../skills/catalog.js';
|
|
34
|
+
import { createSkillActivationSession } from '../skills/broker.js';
|
|
35
|
+
import { formatSkillAttribution } from '../skills/attribution.js';
|
|
21
36
|
const MAX_CROSSCHECK_COMMITS = 5;
|
|
22
37
|
const FIX_RETRY_DELAY_MS = 2 * 60 * 1000;
|
|
23
38
|
const REVIEW_RETRY_DELAY_MS = 2 * 60 * 1000;
|
|
24
|
-
const GIT_PUSH_RETRY_DELAY_MS = 3000;
|
|
25
39
|
// Per-vendor configured timeout (seconds) → execa milliseconds, or undefined when
|
|
26
40
|
// unset so the reviewer falls back to its built-in default. A per-run override
|
|
27
41
|
// (ctx.overrideTimeoutMs, set by --timeout / --crazy / --halfcrazy) always wins
|
|
@@ -49,33 +63,27 @@ function isTransientApiError(err) {
|
|
|
49
63
|
export function getEffectiveStepType(stepType, isRecheckRun) {
|
|
50
64
|
return stepType === 'review' && isRecheckRun ? 'recheck' : stepType;
|
|
51
65
|
}
|
|
52
|
-
|
|
53
|
-
// than the branch's full history. Long-lived integration branches like
|
|
54
|
-
// `staging` accumulate [crosscheck] commits from many merged PRs — counting
|
|
55
|
-
// those would trip the per-PR fix-loop guard immediately and skip fix/recheck.
|
|
56
|
-
//
|
|
57
|
-
// Fails closed: when `origin/<base>` isn't available (e.g. clone fetched the
|
|
58
|
-
// base ref with `base_branch_fetch_skipped`), fall back to the full-history
|
|
59
|
-
// count rather than returning 0. Over-counting can stop fix early; returning 0
|
|
60
|
-
// would silently disable the cap and let runaway fix loops keep pushing.
|
|
61
|
-
export function countCrosscheckCommitsForPR(tmpDir, baseRef) {
|
|
66
|
+
export function countCrosscheckCommitsForPRDetailed(tmpDir, baseRef) {
|
|
62
67
|
const runLog = (args) => execFileSync('git', ['log', '--oneline', ...args], { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
68
|
const count = (out) => out.split('\n').filter(l => l.includes('[crosscheck]')).length;
|
|
64
69
|
try {
|
|
65
|
-
return count(runLog([`origin/${baseRef}..HEAD`]));
|
|
70
|
+
return { count: count(runLog([`origin/${baseRef}..HEAD`])), scoped: true };
|
|
66
71
|
}
|
|
67
72
|
catch {
|
|
68
73
|
// Scoped range unavailable — fall back to full history so the cap still
|
|
69
|
-
// applies.
|
|
70
|
-
//
|
|
74
|
+
// applies. Over-counts when the branch has prior merged crosscheck commits,
|
|
75
|
+
// but that's preferable to bypassing the safety guard.
|
|
71
76
|
try {
|
|
72
|
-
return count(runLog([]));
|
|
77
|
+
return { count: count(runLog([])), scoped: false };
|
|
73
78
|
}
|
|
74
79
|
catch {
|
|
75
|
-
return 0;
|
|
80
|
+
return { count: 0, scoped: false };
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
83
|
}
|
|
84
|
+
export function countCrosscheckCommitsForPR(tmpDir, baseRef) {
|
|
85
|
+
return countCrosscheckCommitsForPRDetailed(tmpDir, baseRef).count;
|
|
86
|
+
}
|
|
79
87
|
export function buildWorkflowCompleteEvent(inputs) {
|
|
80
88
|
const stepValues = Object.values(inputs.results);
|
|
81
89
|
const lastVerdict = stepValues.reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
|
|
@@ -134,6 +142,52 @@ export function exceedsMaxRounds(effectiveType, originalStepType, maxRounds, rou
|
|
|
134
142
|
export function anyFixApplied(results) {
|
|
135
143
|
return Object.values(results).some(r => (r.applied_count ?? 0) > 0);
|
|
136
144
|
}
|
|
145
|
+
export function resolveFixLanding(deliveryMode) {
|
|
146
|
+
switch (deliveryMode) {
|
|
147
|
+
case 'comment': return 'comment';
|
|
148
|
+
case 'commit': return 'branch';
|
|
149
|
+
case 'pull_request': return 'branch-then-separate-pr';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// stepsRun holds every step the runner dispatched, skips included; results holds
|
|
153
|
+
// what each one did. Split them so callers can report a run where nothing
|
|
154
|
+
// happened without re-deriving the reasons from the log file.
|
|
155
|
+
export function summariseStepOutcomes(stepsRun, results) {
|
|
156
|
+
const outcomes = { ran: [], skipped: [] };
|
|
157
|
+
for (const name of new Set(stepsRun)) {
|
|
158
|
+
const result = results[name];
|
|
159
|
+
if (result?.skipped)
|
|
160
|
+
outcomes.skipped.push({ step: name, reason: result.skipReason ?? 'unknown' });
|
|
161
|
+
else
|
|
162
|
+
outcomes.ran.push(name);
|
|
163
|
+
}
|
|
164
|
+
return outcomes;
|
|
165
|
+
}
|
|
166
|
+
// Accumulates outcomes across the fix→recheck rounds of one invocation, so the
|
|
167
|
+
// completion line reports whether the run as a whole did work: a first round
|
|
168
|
+
// that skips everything followed by a round that applies a fix is not a
|
|
169
|
+
// "no step ran" run.
|
|
170
|
+
//
|
|
171
|
+
// Running once wins over skipping any number of times — the step demonstrably
|
|
172
|
+
// happened. A step skipped in several rounds is reported once, carrying its
|
|
173
|
+
// latest reason: that is the state the run ended in, and listing the same step
|
|
174
|
+
// several times reads as several distinct problems.
|
|
175
|
+
export function mergeStepOutcomes(base, next) {
|
|
176
|
+
if (!base)
|
|
177
|
+
return next;
|
|
178
|
+
if (!next)
|
|
179
|
+
return base;
|
|
180
|
+
const ran = [...new Set([...base.ran, ...next.ran])];
|
|
181
|
+
const ranSet = new Set(ran);
|
|
182
|
+
// Map.set on an existing key overwrites the reason but keeps the original
|
|
183
|
+
// position, so order stays first-seen while the reason stays last-seen.
|
|
184
|
+
const skipped = new Map();
|
|
185
|
+
for (const entry of [...base.skipped, ...next.skipped]) {
|
|
186
|
+
if (!ranSet.has(entry.step))
|
|
187
|
+
skipped.set(entry.step, entry.reason);
|
|
188
|
+
}
|
|
189
|
+
return { ran, skipped: [...skipped].map(([step, reason]) => ({ step, reason })) };
|
|
190
|
+
}
|
|
137
191
|
function countComments(reviewText) {
|
|
138
192
|
const bullets = (reviewText.match(/^[-*•]\s/gm) ?? []).length;
|
|
139
193
|
const numbered = (reviewText.match(/^\d+\.\s/gm) ?? []).length;
|
|
@@ -174,16 +228,54 @@ function resolveLimitFallbackVendor(failedVendor, stepType, config) {
|
|
|
174
228
|
const fallback = failedVendor === 'claude' ? 'codex' : 'claude';
|
|
175
229
|
return config.vendors[fallback].enabled && supportsStep(fallback, stepType) ? fallback : null;
|
|
176
230
|
}
|
|
177
|
-
//
|
|
231
|
+
// ─── commit subjects ──────────────────────────────────────────────────────────
|
|
232
|
+
// The vendor is a parameter, not a constant: the vendor that ran the step is
|
|
233
|
+
// known at every call site, and a subject crediting a different one contradicts
|
|
234
|
+
// the Crosscheck-Reviewer trailer on its own commit.
|
|
235
|
+
// The `[crosscheck]` prefix and the step word after it are parsed —
|
|
236
|
+
// countCrosscheckCommitsForPR greps the prefix to enforce the commit cap — so
|
|
237
|
+
// only the trailing attribution varies.
|
|
238
|
+
export function fixCommitSubject(appliedCount, vendor) {
|
|
239
|
+
return `[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by ${vendorDisplayName(vendor)}`;
|
|
240
|
+
}
|
|
241
|
+
export function fixPRCommitSubject(prNumber, vendor) {
|
|
242
|
+
return `[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by ${vendorDisplayName(vendor)}`;
|
|
243
|
+
}
|
|
244
|
+
export function conflictResolveCommitSubject(conflictCount, vendor) {
|
|
245
|
+
return `[crosscheck] resolve: resolve ${conflictCount} conflict${conflictCount !== 1 ? 's' : ''} — by ${vendorDisplayName(vendor)}`;
|
|
246
|
+
}
|
|
247
|
+
// Extends resolveReviewer with a human-origin fallback for the steps that write
|
|
248
|
+
// code (fix, conflict-resolve).
|
|
178
249
|
// Scoped to reviewer: 'origin' only — other reviewer types (claude, codex, auto)
|
|
179
250
|
// already encode explicit vendor intent and need no fallback.
|
|
180
251
|
// When origin is 'human' and no vendor resolved, honours routing.fallback_reviewer
|
|
181
|
-
// so the
|
|
182
|
-
// 'auto'
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
252
|
+
// so the step respects the same routing intent as the review step.
|
|
253
|
+
// 'auto' resolves against the vendors that can actually run stepType, so a step
|
|
254
|
+
// only one vendor supports doesn't fall back to the other and skip a line later.
|
|
255
|
+
// An explicit 'claude'/'codex' is an operator decision and is honoured as written
|
|
256
|
+
// even when that vendor cannot run the step — the caller then reports a precise
|
|
257
|
+
// unsupported-step skip instead of silently substituting a different vendor.
|
|
258
|
+
// null disables the fallback entirely.
|
|
259
|
+
function resolveStepVendor(stepType, stepReviewer, origin, config, fallback) {
|
|
186
260
|
const vendor = resolveReviewer(stepReviewer, origin, config, fallback);
|
|
261
|
+
// Origin detection can assign a vendor that cannot run the step: conflict
|
|
262
|
+
// resolution is Claude-only, so a Codex-origin PR (reviewer: 'origin', origin:
|
|
263
|
+
// 'codex') resolves to 'codex' and the dispatch skips it as unsupported — the
|
|
264
|
+
// conflicts never get resolved. (#284's `Crosscheck-Reviewer: codex` detection
|
|
265
|
+
// introduced this: these crosscheck-authored fix PRs used to detect as 'human'
|
|
266
|
+
// and the auto fallback picked Claude.) Substitute a capable, enabled vendor.
|
|
267
|
+
// Scoped to origin-derived assignment only — an explicit reviewer: claude|codex,
|
|
268
|
+
// reviewer: auto, or routing.fallback_reviewer is an operator decision, left as
|
|
269
|
+
// written so the caller can report the precise unsupported-step skip.
|
|
270
|
+
if (stepReviewer === 'origin' &&
|
|
271
|
+
(origin === 'claude' || origin === 'codex') &&
|
|
272
|
+
vendor !== null &&
|
|
273
|
+
!supportsStep(vendor, stepType)) {
|
|
274
|
+
const capable = vendor === 'claude' ? 'codex' : 'claude';
|
|
275
|
+
if (config.vendors[capable].enabled && supportsStep(capable, stepType)) {
|
|
276
|
+
return { vendor: capable, usedHumanFallback: false, substitutedOriginVendor: vendor };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
187
279
|
if (vendor !== null || origin !== 'human' || stepReviewer !== 'origin') {
|
|
188
280
|
return { vendor, usedHumanFallback: false };
|
|
189
281
|
}
|
|
@@ -194,13 +286,25 @@ export function resolveFixVendor(stepReviewer, origin, config, fallback) {
|
|
|
194
286
|
else if (fb === 'codex')
|
|
195
287
|
humanFallback = config.vendors.codex.enabled ? 'codex' : null;
|
|
196
288
|
else if (fb !== null) {
|
|
197
|
-
// 'auto': prefer codex then claude, same as resolveReviewer's auto path
|
|
198
|
-
|
|
289
|
+
// 'auto': prefer codex then claude, same as resolveReviewer's auto path,
|
|
290
|
+
// narrowed to vendors that support this step type.
|
|
291
|
+
const usable = (v) => config.vendors[v].enabled && supportsStep(v, stepType);
|
|
292
|
+
humanFallback = usable('codex') ? 'codex' : usable('claude') ? 'claude' : null;
|
|
199
293
|
}
|
|
200
294
|
if (!humanFallback)
|
|
201
295
|
return { vendor: null, usedHumanFallback: false };
|
|
202
296
|
return { vendor: humanFallback, usedHumanFallback: true };
|
|
203
297
|
}
|
|
298
|
+
// Exported so callers can detect when the fallback was applied (e.g. for logging).
|
|
299
|
+
export function resolveFixVendor(stepReviewer, origin, config, fallback) {
|
|
300
|
+
return resolveStepVendor('fix', stepReviewer, origin, config, fallback);
|
|
301
|
+
}
|
|
302
|
+
// The default workflow gives conflict-resolve `reviewer: origin`, so every PR
|
|
303
|
+
// crosscheck cannot attribute resolved to null here and skipped with 'no_vendor'
|
|
304
|
+
// — the fix step honoured routing.fallback_reviewer, this one did not.
|
|
305
|
+
export function resolveConflictResolveVendor(stepReviewer, origin, config, fallback) {
|
|
306
|
+
return resolveStepVendor('conflict-resolve', stepReviewer, origin, config, fallback);
|
|
307
|
+
}
|
|
204
308
|
// ─── pr_complexity helpers ────────────────────────────────────────────────────
|
|
205
309
|
const EXT_LANG = {
|
|
206
310
|
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
@@ -238,8 +342,173 @@ function diffBucket(totalLines) {
|
|
|
238
342
|
return 'large';
|
|
239
343
|
return 'xlarge';
|
|
240
344
|
}
|
|
241
|
-
|
|
242
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Builds the input the review strategy classifies on, from the already-cloned
|
|
347
|
+
* working copy rather than the API — the runner has the repo on disk, so this
|
|
348
|
+
* costs one `git diff` instead of a round trip.
|
|
349
|
+
*
|
|
350
|
+
* Returns null when the diff can't be read. Callers then fall back to the
|
|
351
|
+
* configured tier, which is why `quality.tier` stays meaningful under smart mode.
|
|
352
|
+
*/
|
|
353
|
+
export function buildPRContext(ctx) {
|
|
354
|
+
const { tmpDir, pr } = ctx;
|
|
355
|
+
try {
|
|
356
|
+
// execFileSync, not execSync: a git ref may legally contain `;`, `$( )` and
|
|
357
|
+
// backticks, and this value drives routing rather than best-effort logging.
|
|
358
|
+
const raw = execFileSync('git', ['diff', '--numstat', `origin/${pr.base.ref}...HEAD`], { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
359
|
+
if (!raw)
|
|
360
|
+
return null;
|
|
361
|
+
const files = [];
|
|
362
|
+
let additions = 0;
|
|
363
|
+
let deletions = 0;
|
|
364
|
+
for (const line of raw.split('\n')) {
|
|
365
|
+
// numstat: <added>\t<deleted>\t<path>. Binary files report '-' for both.
|
|
366
|
+
const [add, del, ...rest] = line.split('\t');
|
|
367
|
+
const path = rest.join('\t').trim();
|
|
368
|
+
if (!path)
|
|
369
|
+
continue;
|
|
370
|
+
files.push(path);
|
|
371
|
+
additions += parseInt(add, 10) || 0;
|
|
372
|
+
deletions += parseInt(del, 10) || 0;
|
|
373
|
+
}
|
|
374
|
+
if (files.length === 0)
|
|
375
|
+
return null;
|
|
376
|
+
return {
|
|
377
|
+
files,
|
|
378
|
+
additions,
|
|
379
|
+
deletions,
|
|
380
|
+
labels: pr.labels?.map(l => l.name) ?? [],
|
|
381
|
+
title: pr.title,
|
|
382
|
+
baseRef: pr.base.ref,
|
|
383
|
+
...(pr.base.repo.default_branch !== undefined && { defaultBranch: pr.base.repo.default_branch }),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Folds a resolved strategy into the quality config the reviewers receive, so
|
|
392
|
+
* every downstream `quality.tier` read picks up the per-PR decision without
|
|
393
|
+
* threading a new parameter through each vendor signature.
|
|
394
|
+
*
|
|
395
|
+
* A null strategy — fixed mode, or an unreadable diff — returns the config
|
|
396
|
+
* untouched, which is why `quality.tier` remains the documented fallback.
|
|
397
|
+
*/
|
|
398
|
+
export function strategyQuality(quality, strategy) {
|
|
399
|
+
if (!strategy?.tier)
|
|
400
|
+
return quality;
|
|
401
|
+
return { ...quality, tier: strategy.tier };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Applies the class's effort alongside its tier. Without this the strategy's
|
|
405
|
+
* effort was resolved and logged but never sent, so the run line named a level
|
|
406
|
+
* the CLI was never given.
|
|
407
|
+
*
|
|
408
|
+
* `accepted` is the vendor CLI's vocabulary, which is narrower than the model's:
|
|
409
|
+
* the strategy escalates to `xhigh` on round 3 and claude-opus-5 reasons at that
|
|
410
|
+
* level, but the claude CLI has no flag for it, so claudeEffort() mapped the
|
|
411
|
+
* unknown value to `medium` — a round 3 weaker than round 2. Clamp here, where
|
|
412
|
+
* the strategy meets the config, rather than at each CLI.
|
|
413
|
+
*/
|
|
414
|
+
export function strategyVendor(vendor, strategy, accepted) {
|
|
415
|
+
if (!strategy?.effort)
|
|
416
|
+
return vendor;
|
|
417
|
+
const effort = clampToLevels(strategy.effort, accepted);
|
|
418
|
+
if (effort === null)
|
|
419
|
+
return vendor;
|
|
420
|
+
return { ...vendor, effort };
|
|
421
|
+
}
|
|
422
|
+
// NOTE on the asymmetry with `model`: an explicit vendors.*.model is honored
|
|
423
|
+
// over the strategy, but vendors.*.effort is not. That is deliberate rather than
|
|
424
|
+
// an oversight — `effort` carries a schema default, so a parsed config cannot
|
|
425
|
+
// distinguish "the user chose medium" from "nobody set it", and treating the
|
|
426
|
+
// default as a user choice would disable effort escalation for everyone. The
|
|
427
|
+
// override is documented in crosscheck.config.example.yml next to the model
|
|
428
|
+
// note; set `quality.mode: fixed` to keep a hand-set effort on every call.
|
|
429
|
+
/**
|
|
430
|
+
* True when the strategy actually determined the model that ran.
|
|
431
|
+
*
|
|
432
|
+
* Judged from the resolved model rather than from config shape, because two
|
|
433
|
+
* different configs defeat the tier map:
|
|
434
|
+
* - an explicit `vendors.*.model` outranks it, and
|
|
435
|
+
* - codex under subscription auth with no `model`/`model_tiers` resolves every
|
|
436
|
+
* tier to the CLI's own `default`, so fast/balanced/thorough are the same run.
|
|
437
|
+
* In both cases the strategy's tier is not what happened, and citing it would
|
|
438
|
+
* assert a routing decision that never took place — the exact auditability
|
|
439
|
+
* property this feature exists to provide.
|
|
440
|
+
*/
|
|
441
|
+
export function strategyDeterminedModel(vendor, strategy, resolvedModel) {
|
|
442
|
+
if (strategy === null || vendor.model)
|
|
443
|
+
return false;
|
|
444
|
+
// 'default' means the vendor CLI chose, not us.
|
|
445
|
+
return resolvedModel !== 'default';
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Classifies the PR and resolves the strategy, or returns null under
|
|
449
|
+
* `quality.mode: fixed` so the single configured tier applies unchanged.
|
|
450
|
+
*/
|
|
451
|
+
export function resolveStrategyForPR(ctx) {
|
|
452
|
+
if (ctx.config.quality.mode !== 'smart')
|
|
453
|
+
return null;
|
|
454
|
+
const prContext = buildPRContext(ctx);
|
|
455
|
+
if (!prContext)
|
|
456
|
+
return null;
|
|
457
|
+
return resolveReviewStrategy(prContext);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The tier, effort, and vendor configs every step of one round runs under.
|
|
461
|
+
*
|
|
462
|
+
* One function rather than a fold at each use site: the review step ran the
|
|
463
|
+
* escalated strategy while the fix step re-folded the base class, so a promoted
|
|
464
|
+
* round reviewed with the stronger model and then fixed with the weaker one —
|
|
465
|
+
* and took the weaker tier's subprocess timeout with it.
|
|
466
|
+
*
|
|
467
|
+
* Rounds beyond the first escalate: the class tier was already tried and did not
|
|
468
|
+
* resolve the PR, so difficulty is now measured rather than predicted. escalate()
|
|
469
|
+
* raises effort where the model supports it and promotes a tier where it does
|
|
470
|
+
* not, and never weakens the model.
|
|
471
|
+
*/
|
|
472
|
+
export function resolveRoundExecution(config, strategy, round) {
|
|
473
|
+
if (!strategy) {
|
|
474
|
+
return {
|
|
475
|
+
strategy: null,
|
|
476
|
+
quality: config.quality,
|
|
477
|
+
claudeVendor: config.vendors.claude,
|
|
478
|
+
codexVendor: config.vendors.codex,
|
|
479
|
+
roundConfig: config,
|
|
480
|
+
escalated: false,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// The vendors that may actually run this round, each with the model it would
|
|
484
|
+
// use and the vocabulary its CLI accepts. Keyed to the enabled vendors rather
|
|
485
|
+
// than to claude alone: on a codex-only install the claude tier model is never
|
|
486
|
+
// called, so judging escalation by its effort ladder promoted a tier every
|
|
487
|
+
// round while codex sat at the effort it started on.
|
|
488
|
+
const baseQuality = strategyQuality(config.quality, strategy);
|
|
489
|
+
const lanes = [];
|
|
490
|
+
if (config.vendors.claude.enabled) {
|
|
491
|
+
lanes.push({ model: resolveClaudeModel(baseQuality, config.vendors.claude), accepted: CLAUDE_EFFORT_LEVELS });
|
|
492
|
+
}
|
|
493
|
+
if (config.vendors.codex.enabled) {
|
|
494
|
+
lanes.push({ model: resolveCodexModel(baseQuality, config.vendors.codex), accepted: CODEX_EFFORT_LEVELS });
|
|
495
|
+
}
|
|
496
|
+
const escalated = escalate({ tier: strategy.tier ?? config.quality.tier, effort: strategy.effort }, round, lanes);
|
|
497
|
+
const roundStrategy = { ...strategy, tier: escalated.tier, effort: escalated.effort };
|
|
498
|
+
const quality = strategyQuality(config.quality, roundStrategy);
|
|
499
|
+
const claudeVendor = strategyVendor(config.vendors.claude, roundStrategy, CLAUDE_EFFORT_LEVELS);
|
|
500
|
+
const codexVendor = strategyVendor(config.vendors.codex, roundStrategy, CODEX_EFFORT_LEVELS);
|
|
501
|
+
return {
|
|
502
|
+
strategy: roundStrategy,
|
|
503
|
+
quality,
|
|
504
|
+
claudeVendor,
|
|
505
|
+
codexVendor,
|
|
506
|
+
roundConfig: { ...config, quality, vendors: { ...config.vendors, claude: claudeVendor, codex: codexVendor } },
|
|
507
|
+
escalated: escalated.tier !== strategy.tier || escalated.effort !== strategy.effort,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function emitPRComplexity(ctx, triggerField, effectiveTierForRun) {
|
|
511
|
+
const { owner, repoName, prNumber, tmpDir, pr } = ctx;
|
|
243
512
|
try {
|
|
244
513
|
const raw = execSync(`git diff --stat origin/${pr.base.ref}...HEAD`, { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
245
514
|
if (!raw)
|
|
@@ -270,7 +539,7 @@ function emitPRComplexity(ctx, triggerField) {
|
|
|
270
539
|
diff_bucket: diffBucket(insertions + deletions),
|
|
271
540
|
file_mix: mix,
|
|
272
541
|
languages: [...langSet],
|
|
273
|
-
quality_tier:
|
|
542
|
+
quality_tier: effectiveTierForRun,
|
|
274
543
|
...triggerField,
|
|
275
544
|
});
|
|
276
545
|
}
|
|
@@ -287,7 +556,7 @@ async function pushWithNonFastForwardHandling(params) {
|
|
|
287
556
|
const { tmpDir, branch, token, log, fileLog, owner, repoName, prNumber } = params;
|
|
288
557
|
const env = { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token };
|
|
289
558
|
try {
|
|
290
|
-
|
|
559
|
+
execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
|
|
291
560
|
}
|
|
292
561
|
catch (pushErr) {
|
|
293
562
|
const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
@@ -303,11 +572,11 @@ async function pushWithNonFastForwardHandling(params) {
|
|
|
303
572
|
log(chalk.yellow(`⚠ push rejected (non-fast-forward) — fetching latest and rebasing...`));
|
|
304
573
|
try {
|
|
305
574
|
// Fetch the latest state of the branch
|
|
306
|
-
|
|
575
|
+
execFileSync('git', ['fetch', 'origin', branch], { cwd: tmpDir, env, stdio: 'pipe' });
|
|
307
576
|
// Rebase our changes onto the latest branch state
|
|
308
|
-
|
|
577
|
+
execFileSync('git', ['rebase', `origin/${branch}`], { cwd: tmpDir, env, stdio: 'pipe' });
|
|
309
578
|
// Retry the push
|
|
310
|
-
|
|
579
|
+
execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
|
|
311
580
|
fileLog({
|
|
312
581
|
level: 'info',
|
|
313
582
|
event: 'push_rebase_succeeded',
|
|
@@ -339,12 +608,68 @@ async function pushWithNonFastForwardHandling(params) {
|
|
|
339
608
|
export async function runWorkflow(ctx) {
|
|
340
609
|
const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange, trigger } = ctx;
|
|
341
610
|
const triggerField = trigger !== undefined ? { trigger } : {};
|
|
342
|
-
const
|
|
611
|
+
const configuredSteps = (ctx.steps ?? loadWorkflow(process.cwd())).map(step => {
|
|
343
612
|
if (!step.harness || step.instructions)
|
|
344
613
|
return step;
|
|
345
614
|
const resolved = loadHarnessSection(step.harness, process.cwd());
|
|
346
615
|
return resolved ? { ...step, instructions: resolved } : step;
|
|
347
616
|
});
|
|
617
|
+
// Resolved once per runWorkflow call: the fix step pushes commits, so
|
|
618
|
+
// re-classifying per step could yield a different class and make the review
|
|
619
|
+
// and recheck comments cite different tiers for the same PR.
|
|
620
|
+
//
|
|
621
|
+
// Not once per PR: --crazy/--halfcrazy re-enter runWorkflow per round, and by
|
|
622
|
+
// then the diff includes crosscheck's own fix commits, so a later round can
|
|
623
|
+
// legitimately classify differently. Each comment cites the class that
|
|
624
|
+
// produced it, so the record stays accurate either way.
|
|
625
|
+
const strategy = resolveStrategyForPR(ctx);
|
|
626
|
+
if (config.quality.mode === 'smart' && !strategy) {
|
|
627
|
+
// A smart-mode install quietly behaving as fixed is otherwise invisible.
|
|
628
|
+
fileLog({ level: 'warn', event: 'strategy_unresolved', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'pr_context_unavailable', fallback_tier: config.quality.tier });
|
|
629
|
+
}
|
|
630
|
+
else if (strategy) {
|
|
631
|
+
// A config written before `mode` existed parses as smart on upgrade, so a
|
|
632
|
+
// hand-set `quality.tier` can be silently overridden. onboard preserves the
|
|
633
|
+
// old tier by reading raw yaml, but that only helps users who re-run it —
|
|
634
|
+
// so record it here for everyone else.
|
|
635
|
+
//
|
|
636
|
+
// info, not warn: `config.quality.tier` carries a schema default of
|
|
637
|
+
// `balanced` on every install, so the parsed config cannot tell a hand-set
|
|
638
|
+
// tier from an unset one. Five of the eight classes resolve to something
|
|
639
|
+
// other than balanced, which made this fire on the majority of PRs — and
|
|
640
|
+
// recommend a `mode: fixed` opt-out to users who never chose a tier at all.
|
|
641
|
+
// Only the raw yaml can draw that distinction (thoroughnessDefaults), and it
|
|
642
|
+
// is not available on this path.
|
|
643
|
+
if (strategy.tier && strategy.tier !== config.quality.tier) {
|
|
644
|
+
fileLog({ level: 'info', event: 'strategy_overrode_configured_tier', repo: `${owner}/${repoName}`, pr: prNumber, configured_tier: config.quality.tier, applied_tier: strategy.tier, pr_class: strategy.classId });
|
|
645
|
+
}
|
|
646
|
+
fileLog({ level: 'info', event: 'strategy_resolved', repo: `${owner}/${repoName}`, pr: prNumber, strategy_version: strategy.version, pr_class: strategy.classId, tier: strategy.tier, effort: strategy.effort, steps: strategy.steps, domain: strategy.domain });
|
|
647
|
+
}
|
|
648
|
+
// The class's step set NARROWS the configured pipeline; it never widens it.
|
|
649
|
+
// A repo set to review-only stays review-only whatever the class says, which
|
|
650
|
+
// matches how per-repo `crosscheck alter` overrides compose. Reuses
|
|
651
|
+
// filterStepsByTypes so the conflict-resolve rule (orthogonal to the depth
|
|
652
|
+
// ladder, kept only when the depth permits code modification) stays in one
|
|
653
|
+
// place rather than being re-derived here.
|
|
654
|
+
const steps = (() => {
|
|
655
|
+
if (!strategy || strategy.steps.length === 0)
|
|
656
|
+
return configuredSteps;
|
|
657
|
+
const classTypes = strategy.steps.filter((t) => t === 'review' || t === 'fix' || t === 'recheck');
|
|
658
|
+
if (classTypes.length === 0)
|
|
659
|
+
return configuredSteps;
|
|
660
|
+
const narrowed = filterStepsByTypes(configuredSteps, classTypes);
|
|
661
|
+
const dropped = configuredSteps.length - narrowed.length;
|
|
662
|
+
if (dropped > 0) {
|
|
663
|
+
log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → ${classTypes.join(', ')} (${dropped} step${dropped === 1 ? '' : 's'} dropped)`));
|
|
664
|
+
fileLog({ level: 'info', event: 'strategy_steps_narrowed', repo: `${owner}/${repoName}`, pr: prNumber, pr_class: strategy.classId, configured: configuredSteps.map((x) => x.type), applied: narrowed.map((x) => x.type), strategy_version: strategy.version });
|
|
665
|
+
}
|
|
666
|
+
return narrowed;
|
|
667
|
+
})();
|
|
668
|
+
if (strategy && strategy.tier === null) {
|
|
669
|
+
log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → skipped (${strategy.reason})`));
|
|
670
|
+
fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'strategy_class_skip', pr_class: strategy.classId, strategy_version: strategy.version });
|
|
671
|
+
return { verdict: null, strategySkipped: strategy.classId };
|
|
672
|
+
}
|
|
348
673
|
const results = {};
|
|
349
674
|
// SHAs the workflow pushed AND set a `crosscheck/review` pending status on.
|
|
350
675
|
// Each one must be released in the finally below — otherwise the pending
|
|
@@ -356,6 +681,47 @@ export async function runWorkflow(ctx) {
|
|
|
356
681
|
// can iterate the same list and release these shas if SIGINT/SIGTERM fires
|
|
357
682
|
// mid-workflow (process.exit there bypasses our finally below).
|
|
358
683
|
const pushedShasNeedingRelease = ctx.pushedShas ?? [];
|
|
684
|
+
const skillCatalog = config.skills.enabled.length > 0 ? loadSkillCatalog() : [];
|
|
685
|
+
const skillSessions = new Map();
|
|
686
|
+
const skillSessionFor = (stepName, stepType) => {
|
|
687
|
+
if (skillCatalog.length === 0)
|
|
688
|
+
return undefined;
|
|
689
|
+
const existing = skillSessions.get(stepName);
|
|
690
|
+
if (existing)
|
|
691
|
+
return existing;
|
|
692
|
+
const session = createSkillActivationSession(stepType, config.skills.enabled, skillCatalog);
|
|
693
|
+
skillSessions.set(stepName, session);
|
|
694
|
+
return session;
|
|
695
|
+
};
|
|
696
|
+
// Skills were offered and the agent took none. Silent before, which is how a
|
|
697
|
+
// prompt that never triggered activation ran unnoticed for 336 steps.
|
|
698
|
+
// `skills_activated: []` on the *_complete events says something similar but
|
|
699
|
+
// quietly; what is new here is `enabled` (what was actually on offer) and the
|
|
700
|
+
// warn level, which makes a step that activates nothing greppable on its own.
|
|
701
|
+
const logSkillsNoneActivated = (session, identity) => {
|
|
702
|
+
// skillSessionFor hands back a session whenever the catalog is non-empty,
|
|
703
|
+
// but createSkillActivationSession filters that catalog down to
|
|
704
|
+
// config.skills.enabled — so a configured name that never resolved (typo,
|
|
705
|
+
// skill not installed) leaves enabledSkills empty, and then
|
|
706
|
+
// renderSkillBrokerInstructions renders nothing at all. Nothing was offered,
|
|
707
|
+
// so nothing was refused: that is broken config, not agent non-compliance,
|
|
708
|
+
// and logging it here would conflate the two causes the event exists to
|
|
709
|
+
// tell apart.
|
|
710
|
+
if (session.enabledSkills.length === 0)
|
|
711
|
+
return;
|
|
712
|
+
fileLog({ level: 'warn', event: 'skills_none_activated', repo: `${owner}/${repoName}`, pr: prNumber, ...identity, enabled: session.enabledSkills.map(skill => skill.name) });
|
|
713
|
+
};
|
|
714
|
+
// Linear write-back identity. Resolved up front — before any expensive step —
|
|
715
|
+
// and allowed to throw. The contract is that a configured-but-failing
|
|
716
|
+
// client_credentials mint ABORTS rather than degrading, because silently
|
|
717
|
+
// continuing would either drop the write or re-attribute it to a human. This
|
|
718
|
+
// matches commands/review.ts; the two paths must not disagree.
|
|
719
|
+
// The contract is one token per command run. runWorkflow is re-entered for every
|
|
720
|
+
// fix/recheck round under --crazy and max_rounds, so minting here would mint per
|
|
721
|
+
// round and let a late transient failure abort work already done. The caller
|
|
722
|
+
// resolves once and passes it in; resolving here is the single-round fallback.
|
|
723
|
+
// A dry run posts nothing, so it never mints.
|
|
724
|
+
let linearAuth = ctx.linearAuth ?? null;
|
|
359
725
|
let workflowFailed = false;
|
|
360
726
|
let workflowError = undefined;
|
|
361
727
|
let failedStep = undefined;
|
|
@@ -384,15 +750,49 @@ export async function runWorkflow(ctx) {
|
|
|
384
750
|
const workflowStart = Date.now();
|
|
385
751
|
const stepsRun = [];
|
|
386
752
|
let currentStepName;
|
|
387
|
-
|
|
753
|
+
// Class picks tier AND effort under smart; untouched config under fixed.
|
|
754
|
+
// Every step of this round reads from here — review, fix, and recheck alike —
|
|
755
|
+
// so a promoted round cannot review with one model and fix with another.
|
|
756
|
+
//
|
|
757
|
+
// Above the try, and above emitPRComplexity, because both the complexity event
|
|
758
|
+
// and workflow_complete report the tier that ran: an escalated round reporting
|
|
759
|
+
// the base class tier is the same defect as a comment citing one.
|
|
760
|
+
const { strategy: roundStrategy, quality, claudeVendor, codexVendor, roundConfig, escalated } = resolveRoundExecution(config, strategy, ctx.round ?? 1);
|
|
761
|
+
emitPRComplexity(ctx, triggerField, quality.tier);
|
|
388
762
|
try {
|
|
763
|
+
// Inside the try so a preflight failure still reaches the completion handler in
|
|
764
|
+
// the finally — resolving above it meant a failed mint skipped workflow_complete
|
|
765
|
+
// entirely and left no record of the run.
|
|
766
|
+
// canWriteVerdict here too: the caller passes null deliberately when the selected
|
|
767
|
+
// steps cannot write a verdict, and this fallback previously read that as
|
|
768
|
+
// "unresolved" and resolved anyway — defeating the gate one line upstream.
|
|
769
|
+
if (!ctx.dryRun && !linearAuth && linearWritePossible(config.linear, steps)) {
|
|
770
|
+
linearAuth = await resolveLinearAuth(config.linear, getLinearCredentials(config.linear.auth));
|
|
771
|
+
fileLog({ level: 'info', event: 'linear_auth_resolved', repo: `${owner}/${repoName}`, pr: prNumber, mode: linearAuth.mode, actor: linearAuth.actor });
|
|
772
|
+
}
|
|
773
|
+
// Logged once per run, not per step: nothing here depends on `step`, and
|
|
774
|
+
// recomputing inside the loop printed the same line for review and recheck.
|
|
775
|
+
if (strategy && roundStrategy) {
|
|
776
|
+
// Report the effort each vendor was actually GIVEN, not the level the round
|
|
777
|
+
// asked for. The two CLI vocabularies differ, so one round can send codex
|
|
778
|
+
// `xhigh` and claude `high`; printing the request names a level nobody ran.
|
|
779
|
+
const appliedEffort = [...new Set([
|
|
780
|
+
...(config.vendors.claude.enabled ? [claudeVendor.effort] : []),
|
|
781
|
+
...(config.vendors.codex.enabled ? [codexVendor.effort] : []),
|
|
782
|
+
])].join('/');
|
|
783
|
+
const escalatedNote = escalated ? ` · round ${ctx.round} escalated` : '';
|
|
784
|
+
log(chalk.dim(` strategy v${strategy.version}: ${strategy.classId} → ${roundStrategy.tier ?? 'skip'} tier${appliedEffort ? ` (${appliedEffort})` : ''}${escalatedNote}`));
|
|
785
|
+
if (escalatedNote) {
|
|
786
|
+
fileLog({ level: 'info', event: 'strategy_escalated', repo: `${owner}/${repoName}`, pr: prNumber, round: ctx.round, from_tier: strategy.tier, to_tier: roundStrategy.tier, from_effort: strategy.effort, to_effort: roundStrategy.effort, applied_effort_claude: config.vendors.claude.enabled ? claudeVendor.effort : null, applied_effort_codex: config.vendors.codex.enabled ? codexVendor.effort : null, strategy_version: strategy.version });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
389
789
|
for (const step of steps) {
|
|
390
790
|
currentStepName = step.name;
|
|
391
791
|
stepsRun.push(step.name);
|
|
392
792
|
const effectiveType = getEffectiveStepType(step.type, ctx.isRecheckRun === true);
|
|
393
793
|
if (exceedsMaxRounds(effectiveType, step.type, ctx.overrideMaxRounds ?? step.max_rounds, ctx.round)) {
|
|
394
794
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'max_rounds' });
|
|
395
|
-
results[step.name] = { skipped: true };
|
|
795
|
+
results[step.name] = { skipped: true, skipReason: 'max_rounds' };
|
|
396
796
|
if (effectiveType === 'fix')
|
|
397
797
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
398
798
|
else if (effectiveType === 'recheck')
|
|
@@ -404,7 +804,7 @@ export async function runWorkflow(ctx) {
|
|
|
404
804
|
// Evaluate when condition — skip step if false
|
|
405
805
|
if (step.when && !evaluateWhen(step.when, results)) {
|
|
406
806
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'when_condition' });
|
|
407
|
-
results[step.name] = { skipped: true };
|
|
807
|
+
results[step.name] = { skipped: true, skipReason: 'when_condition' };
|
|
408
808
|
if (effectiveType === 'fix')
|
|
409
809
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
410
810
|
else if (effectiveType === 'recheck')
|
|
@@ -418,7 +818,7 @@ export async function runWorkflow(ctx) {
|
|
|
418
818
|
// review coerced to a recheck (isRecheckRun) is never blocked here.
|
|
419
819
|
if (step.type === 'recheck' && reviewRanThisSession && !anyFixApplied(results)) {
|
|
420
820
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_change_since_review' });
|
|
421
|
-
results[step.name] = { skipped: true };
|
|
821
|
+
results[step.name] = { skipped: true, skipReason: 'no_change_since_review' };
|
|
422
822
|
onPhaseChange('', { phase: 'rechecked' });
|
|
423
823
|
continue;
|
|
424
824
|
}
|
|
@@ -427,7 +827,7 @@ export async function runWorkflow(ctx) {
|
|
|
427
827
|
let reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
|
|
428
828
|
if (!reviewer) {
|
|
429
829
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
|
|
430
|
-
results[step.name] = { skipped: true };
|
|
830
|
+
results[step.name] = { skipped: true, skipReason: 'no_reviewer' };
|
|
431
831
|
continue;
|
|
432
832
|
}
|
|
433
833
|
// The recheck step is confirmed to run — clear the pending-recheck guard
|
|
@@ -448,17 +848,21 @@ export async function runWorkflow(ctx) {
|
|
|
448
848
|
let inputTokens;
|
|
449
849
|
let outputTokens;
|
|
450
850
|
let model = 'default';
|
|
851
|
+
let effort;
|
|
451
852
|
let retried;
|
|
853
|
+
const skillSession = skillSessionFor(step.name, effectiveType);
|
|
854
|
+
// Under `quality.mode: smart` the PR's class picks the tier; under fixed
|
|
855
|
+
// this is config.quality untouched.
|
|
452
856
|
const runReviewWithVendor = async (candidate) => {
|
|
453
857
|
if (candidate === 'codex') {
|
|
454
858
|
;
|
|
455
|
-
({ review: rawReview, tokensUsed, model, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title,
|
|
859
|
+
({ review: rawReview, tokensUsed, model, effort, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title, quality, codexVendor, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec), log, ctx.issueContext, skillSession, config.skills.codex_full_access));
|
|
456
860
|
inputTokens = undefined;
|
|
457
861
|
outputTokens = undefined;
|
|
458
862
|
}
|
|
459
863
|
else {
|
|
460
864
|
;
|
|
461
|
-
({ review: rawReview, tokensUsed, inputTokens, outputTokens, model, retried } = await runClaudeReview(tmpDir, pr.base.ref, pr.title,
|
|
865
|
+
({ review: rawReview, tokensUsed, inputTokens, outputTokens, model, effort, retried } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, quality, claudeVendor, config.budget.per_review_usd, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), !!ctx.roundMode, log, ctx.issueContext, skillSession));
|
|
462
866
|
}
|
|
463
867
|
};
|
|
464
868
|
try {
|
|
@@ -515,6 +919,11 @@ export async function runWorkflow(ctx) {
|
|
|
515
919
|
}
|
|
516
920
|
}
|
|
517
921
|
}
|
|
922
|
+
const activatedSkills = skillSession?.activations() ?? [];
|
|
923
|
+
if (activatedSkills.length > 0)
|
|
924
|
+
log(chalk.dim(` skills: ${formatSkillAttribution(activatedSkills)}`));
|
|
925
|
+
else if (skillSession)
|
|
926
|
+
logSkillsNoneActivated(skillSession, stepIdentity);
|
|
518
927
|
// First attempt timed out but the delayed retry succeeded — surface a
|
|
519
928
|
// soft notice on the review comment so the author knows it was a transient blip.
|
|
520
929
|
if (retried) {
|
|
@@ -536,15 +945,22 @@ export async function runWorkflow(ctx) {
|
|
|
536
945
|
const baseBody = verdict === null
|
|
537
946
|
? `${NULL_VERDICT_WARNING}\n\n${clean}`
|
|
538
947
|
: prependVerdictToComment(gate.downgraded ? `${SEVERITY_GATE_NOTE}\n\n${clean}` : clean, verdict);
|
|
948
|
+
// Skills are not folded into the body — postReviewComment renders the
|
|
949
|
+
// receipt beneath the attribution footer.
|
|
539
950
|
const commentBody = retried
|
|
540
951
|
? `${buildRetriedReviewBanner(retried.timeoutMs, retried.delayMs)}\n\n${baseBody}`
|
|
541
952
|
: baseBody;
|
|
542
953
|
const commentCount = countComments(rawReview);
|
|
543
|
-
|
|
954
|
+
// How long the PR waited for a verdict, measured from when its author opened
|
|
955
|
+
// it — the number a team feels, as distinct from duration_ms (how long the
|
|
956
|
+
// reviewer ran). Omitted rather than guessed when the PR event carried no
|
|
957
|
+
// created_at, so the metric never mixes real latencies with invented ones.
|
|
958
|
+
const openToVerdictMs = prOpenToVerdictMs(pr.created_at, verdict);
|
|
959
|
+
fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, ...(openToVerdictMs !== undefined && { open_to_verdict_ms: openToVerdictMs }), tokens_used: tokensUsed, skills_activated: activatedSkills.map(skill => skill.name), ...(inputTokens !== undefined && { input_tokens: inputTokens }), ...(outputTokens !== undefined && { output_tokens: outputTokens }), ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }), ...triggerField });
|
|
544
960
|
// Recheck verdict is stored separately to preserve the original review's commentCount on the board
|
|
545
961
|
const phaseUpdate = isRecheck
|
|
546
|
-
? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier:
|
|
547
|
-
: { verdict, commentCount, phase: donePhase, crTokens: tokensUsed, crReviewer: reviewer, qualityTier:
|
|
962
|
+
? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: quality.tier }
|
|
963
|
+
: { verdict, commentCount, phase: donePhase, crTokens: tokensUsed, crReviewer: reviewer, qualityTier: quality.tier };
|
|
548
964
|
if (ctx.dryRun) {
|
|
549
965
|
onPhaseChange('dry-run — comment not posted', phaseUpdate);
|
|
550
966
|
log(chalk.dim(`\n--- dry-run: comment that would be posted ---\n${commentBody}\n--- end ---`));
|
|
@@ -579,9 +995,79 @@ export async function runWorkflow(ctx) {
|
|
|
579
995
|
annotationSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
|
|
580
996
|
}
|
|
581
997
|
catch { /* fall back to pr.head.sha if git is unavailable */ }
|
|
582
|
-
|
|
998
|
+
// ...but the clone's HEAD is only trustworthy once it is in the repo. A fix
|
|
999
|
+
// commit whose push failed leaves HEAD one commit ahead of the remote, and
|
|
1000
|
+
// stamping it attributes this verdict to code that does not exist — an APPROVE
|
|
1001
|
+
// on a phantom sha clears the previous BLOCK and reads as routine on the PR
|
|
1002
|
+
// page. Refuse to post rather than post an unattributable verdict: a missing
|
|
1003
|
+
// review is recoverable (the run fails, the pending status releases as
|
|
1004
|
+
// failure, the PR stays blocked), a phantom approval is not.
|
|
1005
|
+
const shaVerification = await verifyReviewedSha(octokit, owner, repoName, prNumber, annotationSha);
|
|
1006
|
+
if (!isVerifiedReviewedSha(shaVerification.status)) {
|
|
1007
|
+
const rejection = reviewedShaRejection(shaVerification, annotationSha, verdict);
|
|
1008
|
+
fileLog({ level: 'error', event: 'verdict_sha_unverified', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, verdict, sha: annotationSha, head_sha: shaVerification.headSha, verification: shaVerification.status, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
|
|
1009
|
+
log(chalk.red(`✗ ${rejection}`));
|
|
1010
|
+
// The review itself cost vendor tokens — surface it rather than lose it.
|
|
1011
|
+
log(chalk.dim(`\n--- unposted review ---\n${commentBody}\n--- end ---`));
|
|
1012
|
+
throw new Error(rejection);
|
|
1013
|
+
}
|
|
1014
|
+
if (shaVerification.status === 'descendant') {
|
|
1015
|
+
// The reviewed commit is real but ahead of the PR head — the fix landed on a
|
|
1016
|
+
// separate auto-fix branch. Recorded so a verdict that does not cover HEAD is
|
|
1017
|
+
// explicable from the log rather than looking like the head-matching case.
|
|
1018
|
+
fileLog({ level: 'warn', event: 'verdict_sha_off_head', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, verdict, sha: annotationSha, head_sha: shaVerification.headSha, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
|
|
1019
|
+
}
|
|
1020
|
+
const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck, model, effectiveType, ctx.round ?? 1, annotationSha, nextStepAnnotation, ctx.trigger === 'kickass' ? 'kickass' : undefined, activatedSkills, effort,
|
|
1021
|
+
// Withheld when an explicit vendors.*.model overrode the tier map:
|
|
1022
|
+
// citing a tier the run did not use would assert a routing decision
|
|
1023
|
+
// that never happened.
|
|
1024
|
+
strategyDeterminedModel(reviewer === 'codex' ? config.vendors.codex : config.vendors.claude, roundStrategy, model) && roundStrategy?.tier
|
|
1025
|
+
? { version: roundStrategy.version, classId: roundStrategy.classId, tier: roundStrategy.tier, reason: roundStrategy.reason }
|
|
1026
|
+
: undefined);
|
|
583
1027
|
const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
|
|
584
1028
|
fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
|
|
1029
|
+
// A posted verdict that blocks the merge is the product's whole reason to
|
|
1030
|
+
// exist, so it gets its own event rather than being re-derived downstream.
|
|
1031
|
+
// BLOCK blocks by definition; a NEEDS WORK that reached here survived the
|
|
1032
|
+
// severity gate, which only lets it through when a Critical/High/Medium
|
|
1033
|
+
// finding backs it — a nit-only review was already downgraded to APPROVE.
|
|
1034
|
+
// Logged only here, after the comment actually posted, so dry runs and
|
|
1035
|
+
// failed postReviewComment calls are never counted as posted findings.
|
|
1036
|
+
if (verdict === 'BLOCK' || verdict === 'NEEDS WORK') {
|
|
1037
|
+
fileLog({ level: 'info', event: 'blocking_finding_posted', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
|
|
1038
|
+
}
|
|
1039
|
+
// Mirror the verdict onto the PR's Linear issue. `run` and `watch` both
|
|
1040
|
+
// land here, so this is the path that matters — reviews posted from
|
|
1041
|
+
// commands/review.ts are the exception, not the rule.
|
|
1042
|
+
if (linearAuth && shouldPostToLinear(verdict ?? null, config.linear.comment_on)) {
|
|
1043
|
+
{
|
|
1044
|
+
// Attribute to crosscheck/review, /fix, /recheck rather than a flat actor.
|
|
1045
|
+
const stepAuth = config.linear.identity.per_step_actor ? withWorker(linearAuth, effectiveType) : linearAuth;
|
|
1046
|
+
const linearResult = await notifyLinear({
|
|
1047
|
+
auth: stepAuth,
|
|
1048
|
+
config: config.linear,
|
|
1049
|
+
pr: { branch: pr.head.ref, title: pr.title, body: pr.body ?? '', url: `https://${commentUrl}`, sha: annotationSha },
|
|
1050
|
+
verdict: verdict ?? null,
|
|
1051
|
+
reviewer,
|
|
1052
|
+
origin,
|
|
1053
|
+
model,
|
|
1054
|
+
stepType: effectiveType,
|
|
1055
|
+
round: ctx.round ?? 1,
|
|
1056
|
+
service: config.brand.service_name,
|
|
1057
|
+
});
|
|
1058
|
+
fileLog({
|
|
1059
|
+
level: linearResult.status === 'failed' ? 'warn' : 'info',
|
|
1060
|
+
event: 'linear_comment', repo: `${owner}/${repoName}`, pr: prNumber,
|
|
1061
|
+
status: linearResult.status, reason: linearResult.reason, issue: linearResult.identifier,
|
|
1062
|
+
});
|
|
1063
|
+
if (linearResult.status === 'posted') {
|
|
1064
|
+
log(chalk.dim(` linear: commented on ${linearResult.identifier}`));
|
|
1065
|
+
}
|
|
1066
|
+
else if (linearResult.status === 'failed') {
|
|
1067
|
+
log(chalk.yellow(` linear: write failed — ${linearResult.reason}`));
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
585
1071
|
results[step.name] = { verdict, commentBody, commentUrl, commentId, tokens_used: tokensUsed, input_tokens: inputTokens, output_tokens: outputTokens, vendor: reviewer, model };
|
|
586
1072
|
}
|
|
587
1073
|
}
|
|
@@ -589,7 +1075,7 @@ export async function runWorkflow(ctx) {
|
|
|
589
1075
|
const skipFix = (reason) => {
|
|
590
1076
|
lastFixSkipReason = reason;
|
|
591
1077
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
592
|
-
results[step.name] = { skipped: true };
|
|
1078
|
+
results[step.name] = { skipped: true, skipReason: reason };
|
|
593
1079
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
|
|
594
1080
|
};
|
|
595
1081
|
if (ctx.dryRun) {
|
|
@@ -641,17 +1127,28 @@ export async function runWorkflow(ctx) {
|
|
|
641
1127
|
skipFix('no_vendor');
|
|
642
1128
|
continue;
|
|
643
1129
|
}
|
|
644
|
-
|
|
645
|
-
|
|
1130
|
+
// The fix step holds the review's tier — this round's, escalation
|
|
1131
|
+
// included: it is generation against an explicit findings list, which
|
|
1132
|
+
// models handle well, but a cheap fixer that introduces a regression costs
|
|
1133
|
+
// a whole extra round. Recheck does not step down either — it decides
|
|
1134
|
+
// whether to spend another round, and a weak judge there is how loops run
|
|
1135
|
+
// away.
|
|
1136
|
+
const claudeFixModel = resolveClaudeModel(quality, claudeVendor);
|
|
1137
|
+
const codexFixModel = resolveCodexModel(quality, codexVendor);
|
|
646
1138
|
// Guard: don't push more than MAX_CROSSCHECK_COMMITS per PR.
|
|
647
1139
|
// Scope to commits ahead of base so long-lived branches (e.g. staging)
|
|
648
1140
|
// don't count [crosscheck] commits from previously merged PRs.
|
|
649
1141
|
// Crazy/halfcrazy mode doubles the cap since it deliberately loops.
|
|
650
|
-
const
|
|
1142
|
+
const commitCount = countCrosscheckCommitsForPRDetailed(tmpDir, pr.base.ref);
|
|
651
1143
|
const effectiveCommitLimit = ctx.roundMode ? MAX_CROSSCHECK_COMMITS * 2 : MAX_CROSSCHECK_COMMITS;
|
|
652
|
-
if (
|
|
653
|
-
|
|
654
|
-
|
|
1144
|
+
if (commitCount.count >= effectiveCommitLimit) {
|
|
1145
|
+
// Report the count, not the limit. An unscoped count means origin/<base>
|
|
1146
|
+
// was missing, so this is an over-count from whole-repo history rather
|
|
1147
|
+
// than a real cap hit — say so, or the next person debugs the wrong thing.
|
|
1148
|
+
log(commitCount.scoped
|
|
1149
|
+
? chalk.yellow(`⚠ PR #${prNumber}: ${commitCount.count}/${effectiveCommitLimit} [crosscheck] commits already — stopping auto-fix`)
|
|
1150
|
+
: chalk.yellow(`⚠ PR #${prNumber}: cannot scope [crosscheck] commit count (origin/${pr.base.ref} missing; ${commitCount.count} across all history) — stopping auto-fix`));
|
|
1151
|
+
skipFix(commitCount.scoped ? 'commit_limit_reached' : 'commit_count_unscoped');
|
|
655
1152
|
continue;
|
|
656
1153
|
}
|
|
657
1154
|
onPhaseChange(`${vendor} fixing...`, { phase: 'fixing' });
|
|
@@ -659,18 +1156,24 @@ export async function runWorkflow(ctx) {
|
|
|
659
1156
|
let appliedCount = 0;
|
|
660
1157
|
let fixChangedFiles = [];
|
|
661
1158
|
let fixTokensUsed;
|
|
1159
|
+
let fixEffort;
|
|
662
1160
|
let fixErr = undefined;
|
|
663
1161
|
let activeVendor = vendor;
|
|
664
|
-
|
|
1162
|
+
// The strategy tier, not the configured one: a risky PR runs the thorough
|
|
1163
|
+
// model here, and the balanced 600s budget would cut it off.
|
|
1164
|
+
const tierMs = tierTimeoutMs(quality.tier);
|
|
1165
|
+
const skillSession = skillSessionFor(step.name, effectiveType);
|
|
665
1166
|
const runFix = async (v) => {
|
|
666
1167
|
if (v === 'codex') {
|
|
667
|
-
return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs);
|
|
1168
|
+
return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs, skillSession, codexVendor.effort, config.skills.codex_full_access);
|
|
668
1169
|
}
|
|
669
|
-
|
|
1170
|
+
// roundConfig, not config: runFixStep reads vendors.claude.effort and
|
|
1171
|
+
// quality.tier out of it, and both must be this round's values.
|
|
1172
|
+
return runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', roundConfig, claudeFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec) ?? tierMs, skillSession);
|
|
670
1173
|
};
|
|
671
1174
|
try {
|
|
672
1175
|
;
|
|
673
|
-
({ appliedCount, changedFiles: fixChangedFiles, tokensUsed: fixTokensUsed } = await runFix(vendor));
|
|
1176
|
+
({ appliedCount, changedFiles: fixChangedFiles, tokensUsed: fixTokensUsed, effort: fixEffort } = await runFix(vendor));
|
|
674
1177
|
}
|
|
675
1178
|
catch (err) {
|
|
676
1179
|
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1, vendor }, err);
|
|
@@ -684,7 +1187,7 @@ export async function runWorkflow(ctx) {
|
|
|
684
1187
|
fileLog({ level: 'warn', event: 'fix_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: vendor, to: fallbackVendor, ...(isSubscriptionLimitError(err) && { reason: 'vendor_limit' }) });
|
|
685
1188
|
try {
|
|
686
1189
|
;
|
|
687
|
-
({ appliedCount, changedFiles: fixChangedFiles, tokensUsed: fixTokensUsed } = await runFix(fallbackVendor));
|
|
1190
|
+
({ appliedCount, changedFiles: fixChangedFiles, tokensUsed: fixTokensUsed, effort: fixEffort } = await runFix(fallbackVendor));
|
|
688
1191
|
activeVendor = fallbackVendor;
|
|
689
1192
|
}
|
|
690
1193
|
catch (fallbackErr) {
|
|
@@ -705,7 +1208,7 @@ export async function runWorkflow(ctx) {
|
|
|
705
1208
|
onPhaseChange(`${activeVendor} fixing (retry)...`, { phase: 'fixing' });
|
|
706
1209
|
try {
|
|
707
1210
|
;
|
|
708
|
-
({ appliedCount, changedFiles: fixChangedFiles, tokensUsed: fixTokensUsed } = await runFix(activeVendor));
|
|
1211
|
+
({ appliedCount, changedFiles: fixChangedFiles, tokensUsed: fixTokensUsed, effort: fixEffort } = await runFix(activeVendor));
|
|
709
1212
|
fileLog({ level: 'info', event: 'fix_retry_succeeded', repo: `${owner}/${repoName}`, pr: prNumber });
|
|
710
1213
|
fixErr = undefined;
|
|
711
1214
|
}
|
|
@@ -714,6 +1217,20 @@ export async function runWorkflow(ctx) {
|
|
|
714
1217
|
fixErr = retryErr;
|
|
715
1218
|
}
|
|
716
1219
|
}
|
|
1220
|
+
const activatedSkills = skillSession?.activations() ?? [];
|
|
1221
|
+
if (activatedSkills.length > 0)
|
|
1222
|
+
log(chalk.dim(` skills: ${formatSkillAttribution(activatedSkills)}`));
|
|
1223
|
+
else if (skillSession)
|
|
1224
|
+
logSkillsNoneActivated(skillSession, { step_type: 'fix', step_name: step.name });
|
|
1225
|
+
// Every delivery mode (commit card, fix PR, suggested-diff comment) closes
|
|
1226
|
+
// with the same footer as a review comment.
|
|
1227
|
+
const fixAttributionFooter = () => buildAttributionFooter({
|
|
1228
|
+
action: 'Fixed',
|
|
1229
|
+
vendor: activeVendor,
|
|
1230
|
+
model: activeVendor === 'codex' ? codexFixModel : claudeFixModel,
|
|
1231
|
+
effort: fixEffort,
|
|
1232
|
+
skills: activatedSkills,
|
|
1233
|
+
});
|
|
717
1234
|
if (fixErr !== undefined) {
|
|
718
1235
|
skipFix(isSubscriptionLimitError(fixErr) ? 'vendor_limit' : 'fix_error');
|
|
719
1236
|
// Only notify for transient failures — auth errors are operator issues, not PR author issues
|
|
@@ -722,7 +1239,13 @@ export async function runWorkflow(ctx) {
|
|
|
722
1239
|
const octokit = createGithubClient(token);
|
|
723
1240
|
await octokit.rest.issues.createComment({
|
|
724
1241
|
owner, repo: repoName, issue_number: prNumber,
|
|
725
|
-
body:
|
|
1242
|
+
body: buildFixFailedCommentBody({
|
|
1243
|
+
prUrl: pr.html_url,
|
|
1244
|
+
vendor: activeVendor,
|
|
1245
|
+
model: activeVendor === 'codex' ? codexFixModel : claudeFixModel,
|
|
1246
|
+
effort: fixEffort,
|
|
1247
|
+
skills: activatedSkills,
|
|
1248
|
+
}),
|
|
726
1249
|
});
|
|
727
1250
|
fileLog({ level: 'info', event: 'fix_failed_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber });
|
|
728
1251
|
}
|
|
@@ -741,7 +1264,7 @@ export async function runWorkflow(ctx) {
|
|
|
741
1264
|
// event — NOT `fix_complete` — so status consumers (WORKFLOW_ACTIVITY_EVENTS,
|
|
742
1265
|
// the legacy status fold) don't misread a no-op as real workflow progress and
|
|
743
1266
|
// mark the PR NEEDS_RECHECK or hide it from `scan --tidy` with no fix applied.
|
|
744
|
-
fileLog({ level: 'info', event: 'fix_noop', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: 0, no_changes: true, tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1267
|
+
fileLog({ level: 'info', event: 'fix_noop', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: 0, no_changes: true, tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
745
1268
|
results[step.name] = { applied_count: 0, ...(fixTokensUsed !== undefined && { tokens_used: fixTokensUsed }), vendor };
|
|
746
1269
|
continue;
|
|
747
1270
|
}
|
|
@@ -751,13 +1274,14 @@ export async function runWorkflow(ctx) {
|
|
|
751
1274
|
continue;
|
|
752
1275
|
}
|
|
753
1276
|
const deliveryMode = config.post_review.auto_fix.delivery.mode;
|
|
754
|
-
|
|
1277
|
+
const landing = resolveFixLanding(deliveryMode);
|
|
1278
|
+
if (landing === 'branch') {
|
|
755
1279
|
const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
|
|
756
1280
|
execSync('git add -A', { cwd: tmpDir });
|
|
757
1281
|
execFileSync('git', [
|
|
758
1282
|
'commit',
|
|
759
1283
|
'-m',
|
|
760
|
-
|
|
1284
|
+
fixCommitSubject(appliedCount, activeVendor),
|
|
761
1285
|
'-m',
|
|
762
1286
|
buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
|
|
763
1287
|
], { cwd: tmpDir });
|
|
@@ -793,7 +1317,7 @@ export async function runWorkflow(ctx) {
|
|
|
793
1317
|
}
|
|
794
1318
|
}
|
|
795
1319
|
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
796
|
-
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1320
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
797
1321
|
// Post a summary comment so the silent commit push is visible on the timeline
|
|
798
1322
|
// as a comment card. Best-effort — a failure here must not fail the run.
|
|
799
1323
|
try {
|
|
@@ -804,6 +1328,9 @@ export async function runWorkflow(ctx) {
|
|
|
804
1328
|
changedFiles: fixChangedFiles,
|
|
805
1329
|
vendor: activeVendor,
|
|
806
1330
|
reviewCommentBody,
|
|
1331
|
+
model: activeVendor === 'codex' ? codexFixModel : claudeFixModel,
|
|
1332
|
+
effort: fixEffort,
|
|
1333
|
+
skills: activatedSkills,
|
|
807
1334
|
});
|
|
808
1335
|
await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
|
|
809
1336
|
fileLog({ level: 'info', event: 'fix_applied_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha });
|
|
@@ -813,46 +1340,306 @@ export async function runWorkflow(ctx) {
|
|
|
813
1340
|
}
|
|
814
1341
|
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
815
1342
|
}
|
|
816
|
-
else if (
|
|
1343
|
+
else if (landing === 'branch-then-separate-pr') {
|
|
817
1344
|
const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
1345
|
+
// Commit the fix on the PR's own branch (already checked out in tmpDir) and
|
|
1346
|
+
// try to push it there, so the fix, recheck, and approval all stay on the
|
|
1347
|
+
// original PR. Only when that push can't land — e.g. the PR was merged and
|
|
1348
|
+
// its branch deleted, or the branch is protected — do we fall back to opening
|
|
1349
|
+
// a separate follow-up PR that carries the very same commit.
|
|
821
1350
|
execSync('git add -A', { cwd: tmpDir });
|
|
822
1351
|
execFileSync('git', [
|
|
823
1352
|
'commit',
|
|
824
1353
|
'-m',
|
|
825
|
-
|
|
1354
|
+
fixPRCommitSubject(prNumber, activeVendor),
|
|
826
1355
|
'-m',
|
|
827
1356
|
buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
|
|
828
1357
|
], { cwd: tmpDir });
|
|
829
1358
|
const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1359
|
+
let landedOnBranch = false;
|
|
1360
|
+
try {
|
|
1361
|
+
await pushWithNonFastForwardHandling({
|
|
1362
|
+
tmpDir,
|
|
1363
|
+
branch: pr.head.ref,
|
|
1364
|
+
token,
|
|
1365
|
+
log,
|
|
1366
|
+
fileLog: (entry) => fileLog({ ...entry, phase: 'fix' }),
|
|
1367
|
+
owner,
|
|
1368
|
+
repoName,
|
|
1369
|
+
prNumber,
|
|
1370
|
+
});
|
|
1371
|
+
landedOnBranch = true;
|
|
1372
|
+
}
|
|
1373
|
+
catch (pushErr) {
|
|
1374
|
+
fileLog({ level: 'warn', event: 'fix_branch_push_fell_back', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, branch: pr.head.ref, error: pushErr instanceof Error ? pushErr.message.slice(0, 500) : String(pushErr), fallback: 'pull_request' });
|
|
1375
|
+
}
|
|
834
1376
|
ctx.crosscheckShas.add(newSha);
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1377
|
+
if (landedOnBranch) {
|
|
1378
|
+
// Fix landed on the PR branch — mirror `commit` delivery exactly: set the
|
|
1379
|
+
// pending status the following recheck releases, and post the fix-applied
|
|
1380
|
+
// comment so the push is visible on the PR timeline.
|
|
1381
|
+
const currentStepIdx = steps.indexOf(step);
|
|
1382
|
+
const hasRecheckAfterFix = steps.slice(currentStepIdx + 1).some(s => s.type === 'review' || s.type === 'recheck');
|
|
1383
|
+
if (hasRecheckAfterFix) {
|
|
1384
|
+
try {
|
|
1385
|
+
const lockOctokit = createGithubClient(token);
|
|
1386
|
+
await acquireRemoteLock(lockOctokit, owner, repoName, newSha);
|
|
1387
|
+
pushedShasNeedingRelease.push(newSha);
|
|
1388
|
+
fixPushedShaRequiresRecheck = newSha;
|
|
1389
|
+
}
|
|
1390
|
+
catch (err) {
|
|
1391
|
+
fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
1395
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
846
1396
|
try {
|
|
847
|
-
|
|
848
|
-
|
|
1397
|
+
const octokit = createGithubClient(token);
|
|
1398
|
+
const body = buildFixAppliedCommentBody({
|
|
1399
|
+
owner, repo: repoName, sha: newSha, appliedCount,
|
|
1400
|
+
reviewCommentId,
|
|
1401
|
+
changedFiles: fixChangedFiles,
|
|
1402
|
+
vendor: activeVendor,
|
|
1403
|
+
reviewCommentBody,
|
|
1404
|
+
model: activeVendor === 'codex' ? codexFixModel : claudeFixModel,
|
|
1405
|
+
effort: fixEffort,
|
|
1406
|
+
skills: activatedSkills,
|
|
849
1407
|
});
|
|
1408
|
+
await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
|
|
1409
|
+
fileLog({ level: 'info', event: 'fix_applied_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha });
|
|
1410
|
+
}
|
|
1411
|
+
catch (err) {
|
|
1412
|
+
fileLog({ level: 'warn', event: 'fix_applied_comment_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
|
|
850
1413
|
}
|
|
851
|
-
|
|
1414
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
// Fallback: the fix could not land on the PR branch. Push the same commit to a
|
|
1418
|
+
// dedicated branch and open a follow-up PR targeting the original branch.
|
|
1419
|
+
const fixBranch = autoFixBranchName(prNumber);
|
|
1420
|
+
const gitEnv = { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token };
|
|
1421
|
+
const octokit = createGithubClient(token);
|
|
1422
|
+
// The fallback commit exists only in this clone until something pushes it, so
|
|
1423
|
+
// every path that ends without delivering it as a PR has to put HEAD back on the
|
|
1424
|
+
// PR head. Otherwise a later recheck reviews that commit and stamps its verdict
|
|
1425
|
+
// either with a sha the repository cannot resolve — which verifyReviewedSha
|
|
1426
|
+
// refuses to post (#290) — or with one no open PR carries.
|
|
1427
|
+
const restorePRHeadInClone = () => {
|
|
1428
|
+
execSync('git reset --hard HEAD~1', { cwd: tmpDir, stdio: 'pipe' });
|
|
1429
|
+
};
|
|
1430
|
+
// Deliver the fix as a diff on the original PR. Used wherever opening the
|
|
1431
|
+
// follow-up PR would either create a stale artifact or overwrite a branch that
|
|
1432
|
+
// is not ours: the work still reaches a human, just not as a branch.
|
|
1433
|
+
const deliverFixAsComment = async (outcome) => {
|
|
1434
|
+
fileLog({ level: 'warn', event: outcome.event, repo: `${owner}/${repoName}`, pr: prNumber, branch: outcome.branch, reason: outcome.reason });
|
|
1435
|
+
log(chalk.yellow(`⚠ ${outcome.notice}`));
|
|
1436
|
+
let patch = '';
|
|
1437
|
+
try {
|
|
1438
|
+
patch = execSync('git diff HEAD~1', { cwd: tmpDir, encoding: 'utf8' });
|
|
1439
|
+
}
|
|
1440
|
+
catch { /* fall through to the empty check */ }
|
|
1441
|
+
if (patch) {
|
|
1442
|
+
await octokit.rest.issues.createComment({
|
|
1443
|
+
owner, repo: repoName, issue_number: prNumber,
|
|
1444
|
+
body: [
|
|
1445
|
+
'### Suggested fixes (crosscheck auto-fix)',
|
|
1446
|
+
'',
|
|
1447
|
+
outcome.explanation,
|
|
1448
|
+
'',
|
|
1449
|
+
'```diff',
|
|
1450
|
+
patch.slice(0, 16000),
|
|
1451
|
+
'```',
|
|
1452
|
+
'',
|
|
1453
|
+
fixAttributionFooter(),
|
|
1454
|
+
].join('\n'),
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
restorePRHeadInClone();
|
|
1458
|
+
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
1459
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'comment', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1460
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
1461
|
+
};
|
|
1462
|
+
// Ask whether the intended base still exists BEFORE pushing anything. The most
|
|
1463
|
+
// common reason the PR-branch push failed is that the PR merged and GitHub
|
|
1464
|
+
// deleted its branch — and `pulls.create` rejects a missing base with
|
|
1465
|
+
// `field: base, code: invalid`. That used to throw after the branch was already
|
|
1466
|
+
// pushed, losing the fix and orphaning the branch (19 times in logged runs).
|
|
1467
|
+
let baseBranchExists = true;
|
|
1468
|
+
try {
|
|
1469
|
+
await octokit.rest.repos.getBranch({ owner, repo: repoName, branch: pr.head.ref });
|
|
1470
|
+
}
|
|
1471
|
+
catch (err) {
|
|
1472
|
+
if (err?.status === 404)
|
|
1473
|
+
baseBranchExists = false;
|
|
1474
|
+
else
|
|
1475
|
+
throw err;
|
|
1476
|
+
}
|
|
1477
|
+
const delivery = planAutoFixDelivery(baseBranchExists, pr.head.ref);
|
|
1478
|
+
if (delivery.kind === 'comment') {
|
|
1479
|
+
// A PR cut from this snapshot would be stale the moment it opened — it is
|
|
1480
|
+
// exactly the artifact the superseded-auto-fix cleanup exists to close. Post
|
|
1481
|
+
// the fix as a diff instead, so the work is still delivered and reviewable.
|
|
1482
|
+
await deliverFixAsComment({
|
|
1483
|
+
event: 'fix_pr_skipped',
|
|
1484
|
+
reason: delivery.reason,
|
|
1485
|
+
branch: pr.head.ref,
|
|
1486
|
+
notice: `${pr.head.ref} no longer exists — posting the fix as a diff instead of opening a stale follow-up PR`,
|
|
1487
|
+
explanation: `\`${pr.head.ref}\` no longer exists, so this PR has merged and there is no branch to land on. The fix is below rather than in a follow-up PR — a PR cut from the pre-merge state would reintroduce whatever the merged version changed.`,
|
|
1488
|
+
});
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
// A fix branch left behind by an earlier round of this PR made a plain push
|
|
1492
|
+
// non-fast-forward and lost the fix (12 times in logged runs). Replace it under
|
|
1493
|
+
// a lease: this round's fix is built on the newer PR head and supersedes it,
|
|
1494
|
+
// and the lease still refuses if anything other than us moved the branch.
|
|
1495
|
+
let remoteOid = null;
|
|
1496
|
+
try {
|
|
1497
|
+
remoteOid = parseLsRemoteOid(execFileSync('git', ['ls-remote', '--heads', 'origin', fixBranch], { cwd: tmpDir, env: gitEnv, encoding: 'utf8' }));
|
|
1498
|
+
}
|
|
1499
|
+
catch { /* treat an unreadable remote as "branch absent"; the lease catches a wrong guess */ }
|
|
1500
|
+
// `--force-with-lease` only protects against changes made *after* this lookup —
|
|
1501
|
+
// it happily accepts a branch a human just created or moved as the lease value
|
|
1502
|
+
// and overwrites it. So establish that the branch is crosscheck's own artifact
|
|
1503
|
+
// before replacing it, from GitHub's record of who opened the PR from it rather
|
|
1504
|
+
// than from anything the pusher can write into a commit.
|
|
1505
|
+
if (remoteOid !== null) {
|
|
1506
|
+
let crosscheckLogin = null;
|
|
1507
|
+
let candidates = [];
|
|
1508
|
+
try {
|
|
1509
|
+
const [{ data: viewer }, { data: branchPrs }] = await Promise.all([
|
|
1510
|
+
octokit.rest.users.getAuthenticated(),
|
|
1511
|
+
octokit.rest.pulls.list({ owner, repo: repoName, state: 'all', head: `${owner}:${fixBranch}` }),
|
|
1512
|
+
]);
|
|
1513
|
+
crosscheckLogin = viewer.login;
|
|
1514
|
+
candidates = branchPrs;
|
|
1515
|
+
}
|
|
1516
|
+
catch { /* unreadable identity or PR list — treated as not ours, never overwrite */ }
|
|
1517
|
+
const ownership = assessFixBranchOwnership({ sourcePrNumber: prNumber, crosscheckLogin, candidates });
|
|
1518
|
+
if (!ownership.owned) {
|
|
1519
|
+
await deliverFixAsComment({
|
|
1520
|
+
event: 'fix_branch_push_skipped',
|
|
1521
|
+
reason: ownership.reason,
|
|
1522
|
+
branch: fixBranch,
|
|
1523
|
+
notice: `${fixBranch} exists but crosscheck has no PR of its own from it — leaving it in place and posting the fix as a diff instead`,
|
|
1524
|
+
explanation: `\`${fixBranch}\` already exists and crosscheck cannot show it opened a pull request from it, so the branch was left untouched rather than force-replaced. The fix is posted here instead.`,
|
|
1525
|
+
});
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// From #296: the merged-PR handler sweeps for auto-fix PRs once, so a PR opened
|
|
1530
|
+
// after that sweep is never seen by it and stays open and mergeable against a
|
|
1531
|
+
// tree that no longer exists. The base-exists check above already covers a merge
|
|
1532
|
+
// that deleted the branch; this catches a merge in a repo that keeps its
|
|
1533
|
+
// branches. It sits immediately before the push — every lookup above widens the
|
|
1534
|
+
// window it has to close, and nothing has been written to the remote yet, so a
|
|
1535
|
+
// skip here cannot orphan a branch.
|
|
1536
|
+
if (await sourcePRHasMerged(octokit, owner, repoName, prNumber)) {
|
|
1537
|
+
restorePRHeadInClone();
|
|
1538
|
+
skipFix('source_pr_merged');
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
try {
|
|
1542
|
+
execFileSync('git', forceWithLeaseArgs(fixBranch, remoteOid), { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
1543
|
+
}
|
|
1544
|
+
catch (pushErr) {
|
|
1545
|
+
const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
1546
|
+
fileLog({
|
|
1547
|
+
level: 'error', event: 'fix_branch_push_failed', repo: `${owner}/${repoName}`, pr: prNumber,
|
|
1548
|
+
branch: fixBranch, lease: remoteOid ?? 'absent',
|
|
1549
|
+
reason: isLeaseRejection(pushMsg) ? 'lease_rejected' : 'push_failed',
|
|
1550
|
+
error: pushMsg.slice(0, 500),
|
|
1551
|
+
});
|
|
1552
|
+
throw pushErr;
|
|
1553
|
+
}
|
|
1554
|
+
if (remoteOid !== null) {
|
|
1555
|
+
fileLog({ level: 'info', event: 'fix_branch_replaced', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, previous_sha: remoteOid, sha: newSha });
|
|
1556
|
+
}
|
|
1557
|
+
// Undo this run's push when the follow-up PR turns out not to be creatable after
|
|
1558
|
+
// all. Only a branch this run brought into existence is removed — one that was
|
|
1559
|
+
// already crosscheck's is left at the newer commit rather than deleted out from
|
|
1560
|
+
// under whatever else references it.
|
|
1561
|
+
const rollBackPushedFixBranch = () => {
|
|
1562
|
+
if (remoteOid !== null)
|
|
1563
|
+
return;
|
|
1564
|
+
try {
|
|
1565
|
+
execFileSync('git', ['push', 'origin', '--delete', fixBranch], { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
1566
|
+
fileLog({ level: 'info', event: 'fix_branch_rolled_back', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, sha: newSha });
|
|
1567
|
+
}
|
|
1568
|
+
catch (deleteErr) {
|
|
1569
|
+
fileLog({ level: 'warn', event: 'fix_branch_rollback_failed', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, error: deleteErr instanceof Error ? deleteErr.message.slice(0, 500) : String(deleteErr) });
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
// The source PR can still merge in the window between the check above and this
|
|
1573
|
+
// point. Asking again here is what keeps the push from becoming a stale fix PR:
|
|
1574
|
+
// the branch is already on the remote, so the answer decides whether it stays.
|
|
1575
|
+
if (await sourcePRHasMerged(octokit, owner, repoName, prNumber)) {
|
|
1576
|
+
rollBackPushedFixBranch();
|
|
1577
|
+
restorePRHeadInClone();
|
|
1578
|
+
skipFix('source_pr_merged');
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
const fixPrTitle = config.post_review.auto_fix.delivery.pr_title.replace('#{original_pr_title}', pr.title);
|
|
1582
|
+
// An earlier round may already have an open PR from this branch; the force-push
|
|
1583
|
+
// above updated it, so opening a second one would be rejected as a duplicate.
|
|
1584
|
+
const { data: existingFixPrs } = await octokit.rest.pulls.list({
|
|
1585
|
+
owner, repo: repoName, state: 'open', head: `${owner}:${fixBranch}`,
|
|
1586
|
+
});
|
|
1587
|
+
let fixPrNumber;
|
|
1588
|
+
if (existingFixPrs.length > 0) {
|
|
1589
|
+
fixPrNumber = existingFixPrs[0].number;
|
|
1590
|
+
fileLog({ level: 'info', event: 'fix_pr_updated', repo: `${owner}/${repoName}`, pr: prNumber, fix_pr: fixPrNumber, sha: newSha });
|
|
1591
|
+
}
|
|
1592
|
+
else {
|
|
1593
|
+
let fixPr;
|
|
1594
|
+
try {
|
|
1595
|
+
const created = await octokit.rest.pulls.create({
|
|
1596
|
+
owner,
|
|
1597
|
+
repo: repoName,
|
|
1598
|
+
head: fixBranch,
|
|
1599
|
+
base: delivery.base,
|
|
1600
|
+
title: fixPrTitle,
|
|
1601
|
+
body: [
|
|
1602
|
+
autoFixPRIntro(prNumber),
|
|
1603
|
+
'',
|
|
1604
|
+
`Review: https://github.com/${owner}/${repoName}/pull/${prNumber}`,
|
|
1605
|
+
'',
|
|
1606
|
+
fixAttributionFooter(),
|
|
1607
|
+
].join('\n'),
|
|
1608
|
+
});
|
|
1609
|
+
fixPr = created.data;
|
|
1610
|
+
}
|
|
1611
|
+
catch (createErr) {
|
|
1612
|
+
// A merge landing in a repo that deletes its branches takes the base away
|
|
1613
|
+
// between the checks above and this call, and `pulls.create` reports that as
|
|
1614
|
+
// `field: base, code: invalid` — the original failure this fallback exists to
|
|
1615
|
+
// stop, just in a narrower window. Take the branch back out and deliver the
|
|
1616
|
+
// fix as a diff rather than leaving it orphaned.
|
|
1617
|
+
if (!isInvalidBaseError(createErr))
|
|
1618
|
+
throw createErr;
|
|
1619
|
+
rollBackPushedFixBranch();
|
|
1620
|
+
await deliverFixAsComment({
|
|
1621
|
+
event: 'fix_pr_create_failed',
|
|
1622
|
+
reason: 'base_branch_gone_after_push',
|
|
1623
|
+
branch: delivery.base,
|
|
1624
|
+
notice: `${delivery.base} disappeared while the fix branch was being pushed — posting the fix as a diff instead of opening a stale follow-up PR`,
|
|
1625
|
+
explanation: `\`${delivery.base}\` existed when this fix started and was gone by the time the follow-up PR was opened, so this PR merged mid-run. The fix is below rather than in a follow-up PR — a PR cut from the pre-merge state would reintroduce whatever the merged version changed.`,
|
|
1626
|
+
});
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
fixPrNumber = fixPr.number;
|
|
1630
|
+
}
|
|
1631
|
+
if (config.post_review.auto_fix.delivery.label) {
|
|
1632
|
+
try {
|
|
1633
|
+
await octokit.rest.issues.addLabels({
|
|
1634
|
+
owner, repo: repoName, issue_number: fixPrNumber, labels: [config.post_review.auto_fix.delivery.label],
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
catch { /* label may not exist in this repo — skip */ }
|
|
1638
|
+
}
|
|
1639
|
+
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
1640
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPrNumber, tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1641
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
852
1642
|
}
|
|
853
|
-
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
854
|
-
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPr.number, tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
855
|
-
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
856
1643
|
}
|
|
857
1644
|
else {
|
|
858
1645
|
// comment: post the diff as a suggested-fix comment, no code push needed (works for fork PRs too)
|
|
@@ -863,18 +1650,18 @@ export async function runWorkflow(ctx) {
|
|
|
863
1650
|
catch { /* ignore */ }
|
|
864
1651
|
if (patch) {
|
|
865
1652
|
const octokit = createGithubClient(token);
|
|
866
|
-
const body = `### Suggested fixes (crosscheck auto-fix)\n\n\`\`\`diff\n${patch.slice(0, 16000)}\n
|
|
1653
|
+
const body = `### Suggested fixes (crosscheck auto-fix)\n\n\`\`\`diff\n${patch.slice(0, 16000)}\n\`\`\`\n\n${fixAttributionFooter()}`;
|
|
867
1654
|
await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
|
|
868
1655
|
}
|
|
869
1656
|
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
870
|
-
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1657
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
871
1658
|
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
872
1659
|
}
|
|
873
1660
|
}
|
|
874
1661
|
else if (effectiveType === 'conflict-resolve') {
|
|
875
1662
|
const skipConflictResolve = (reason) => {
|
|
876
1663
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
877
|
-
results[step.name] = { skipped: true };
|
|
1664
|
+
results[step.name] = { skipped: true, skipReason: reason };
|
|
878
1665
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
|
|
879
1666
|
};
|
|
880
1667
|
if (ctx.dryRun) {
|
|
@@ -921,7 +1708,16 @@ export async function runWorkflow(ctx) {
|
|
|
921
1708
|
skipConflictResolve('no_conflicts');
|
|
922
1709
|
continue;
|
|
923
1710
|
}
|
|
924
|
-
|
|
1711
|
+
// resolveConflictResolveVendor extends resolveReviewer with the same human-origin
|
|
1712
|
+
// fallback the fix step uses, so a PR crosscheck cannot attribute still gets its
|
|
1713
|
+
// conflicts resolved instead of skipping with 'no_vendor'.
|
|
1714
|
+
const { vendor, usedHumanFallback, substitutedOriginVendor } = resolveConflictResolveVendor(step.reviewer, origin, config, ctx.smartSwitchFallback);
|
|
1715
|
+
if (usedHumanFallback && vendor) {
|
|
1716
|
+
fileLog({ level: 'info', event: 'conflict_resolve_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: 'none', to: vendor, reason: 'human_origin' });
|
|
1717
|
+
}
|
|
1718
|
+
else if (substitutedOriginVendor && vendor) {
|
|
1719
|
+
fileLog({ level: 'info', event: 'conflict_resolve_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: substitutedOriginVendor, to: vendor, reason: 'unsupported_vendor' });
|
|
1720
|
+
}
|
|
925
1721
|
if (!vendor) {
|
|
926
1722
|
try {
|
|
927
1723
|
execSync('git merge --abort', { cwd: tmpDir });
|
|
@@ -940,7 +1736,9 @@ export async function runWorkflow(ctx) {
|
|
|
940
1736
|
skipConflictResolve('codex_conflict_resolve_unsupported');
|
|
941
1737
|
continue;
|
|
942
1738
|
}
|
|
943
|
-
|
|
1739
|
+
// Conflict-resolve is mechanical text surgery bounded by the markers —
|
|
1740
|
+
// measured at 37s against ~643s for a review — so it always runs fast.
|
|
1741
|
+
const conflictResolveModel = resolveClaudeModel({ ...config.quality, tier: config.quality.mode === 'smart' ? 'fast' : config.quality.tier }, config.vendors.claude);
|
|
944
1742
|
const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
|
|
945
1743
|
if (isFork) {
|
|
946
1744
|
try {
|
|
@@ -951,13 +1749,15 @@ export async function runWorkflow(ctx) {
|
|
|
951
1749
|
skipConflictResolve('fork_pr');
|
|
952
1750
|
continue;
|
|
953
1751
|
}
|
|
954
|
-
const
|
|
955
|
-
if (
|
|
1752
|
+
const crCommitCount = countCrosscheckCommitsForPRDetailed(tmpDir, pr.base.ref);
|
|
1753
|
+
if (crCommitCount.count >= MAX_CROSSCHECK_COMMITS) {
|
|
956
1754
|
try {
|
|
957
1755
|
execSync('git merge --abort', { cwd: tmpDir });
|
|
958
1756
|
}
|
|
959
1757
|
catch { /* ignore */ }
|
|
960
|
-
log(
|
|
1758
|
+
log(crCommitCount.scoped
|
|
1759
|
+
? chalk.yellow(`⚠ PR #${prNumber}: ${crCommitCount.count}/${MAX_CROSSCHECK_COMMITS} [crosscheck] commits already — stopping conflict-resolve`)
|
|
1760
|
+
: chalk.yellow(`⚠ PR #${prNumber}: cannot scope [crosscheck] commit count (origin/${pr.base.ref} missing) — stopping conflict-resolve`));
|
|
961
1761
|
skipConflictResolve('commit_limit_reached');
|
|
962
1762
|
continue;
|
|
963
1763
|
}
|
|
@@ -967,9 +1767,11 @@ export async function runWorkflow(ctx) {
|
|
|
967
1767
|
let appliedCount = 0;
|
|
968
1768
|
let resolvedPaths = [];
|
|
969
1769
|
let resolveTokensUsed;
|
|
1770
|
+
let resolveEffort;
|
|
1771
|
+
const skillSession = skillSessionFor(step.name, effectiveType);
|
|
970
1772
|
try {
|
|
971
1773
|
;
|
|
972
|
-
({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? '', conflictResolveModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec)));
|
|
1774
|
+
({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed, effort: resolveEffort } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? '', conflictResolveModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), skillSession, config.vendors.claude.effort));
|
|
973
1775
|
}
|
|
974
1776
|
catch (err) {
|
|
975
1777
|
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'conflict-resolve', attempt: 1 }, err);
|
|
@@ -980,6 +1782,11 @@ export async function runWorkflow(ctx) {
|
|
|
980
1782
|
skipConflictResolve(isSubscriptionLimitError(err) ? 'vendor_limit' : 'resolve_error');
|
|
981
1783
|
continue;
|
|
982
1784
|
}
|
|
1785
|
+
const activatedSkills = skillSession?.activations() ?? [];
|
|
1786
|
+
if (activatedSkills.length > 0)
|
|
1787
|
+
log(chalk.dim(` skills: ${formatSkillAttribution(activatedSkills)}`));
|
|
1788
|
+
else if (skillSession)
|
|
1789
|
+
logSkillsNoneActivated(skillSession, { step_type: 'conflict-resolve', step_name: step.name });
|
|
983
1790
|
if (appliedCount === 0) {
|
|
984
1791
|
try {
|
|
985
1792
|
execSync('git merge --abort', { cwd: tmpDir });
|
|
@@ -1052,7 +1859,7 @@ export async function runWorkflow(ctx) {
|
|
|
1052
1859
|
execFileSync('git', [
|
|
1053
1860
|
'commit',
|
|
1054
1861
|
'-m',
|
|
1055
|
-
|
|
1862
|
+
conflictResolveCommitSubject(conflictedFiles.length, vendor),
|
|
1056
1863
|
'-m',
|
|
1057
1864
|
buildCommitTrailers({ reviewer: vendor, model: conflictResolveModel, step: 'conflict-resolve', service: 'crosscheck' }),
|
|
1058
1865
|
], { cwd: tmpDir });
|
|
@@ -1082,7 +1889,7 @@ export async function runWorkflow(ctx) {
|
|
|
1082
1889
|
fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
|
|
1083
1890
|
}
|
|
1084
1891
|
onPhaseChange('conflicts resolved ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: resolveTokensUsed });
|
|
1085
|
-
fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed, duration_ms: Date.now() - conflictResolveStepStart, ...triggerField });
|
|
1892
|
+
fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - conflictResolveStepStart, ...triggerField });
|
|
1086
1893
|
// Post a summary comment so the silent merge-commit push is visible on the
|
|
1087
1894
|
// timeline as a comment card. Best-effort — a failure here must not fail the run.
|
|
1088
1895
|
// Prefer the resolver's actual rewrite set; fall back to the originally-conflicted
|
|
@@ -1093,6 +1900,9 @@ export async function runWorkflow(ctx) {
|
|
|
1093
1900
|
owner, repo: repoName, sha: newSha,
|
|
1094
1901
|
conflictCount: conflictedFiles.length,
|
|
1095
1902
|
files: resolvedPaths.length > 0 ? resolvedPaths : conflictedFiles,
|
|
1903
|
+
model: conflictResolveModel,
|
|
1904
|
+
effort: resolveEffort,
|
|
1905
|
+
skills: activatedSkills,
|
|
1096
1906
|
});
|
|
1097
1907
|
await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
|
|
1098
1908
|
fileLog({ level: 'info', event: 'conflict_resolved_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha });
|
|
@@ -1113,6 +1923,7 @@ export async function runWorkflow(ctx) {
|
|
|
1113
1923
|
return {
|
|
1114
1924
|
verdict: verdict ?? null,
|
|
1115
1925
|
fixAppliedCount,
|
|
1926
|
+
stepOutcomes: summariseStepOutcomes(stepsRun, results),
|
|
1116
1927
|
...(fixAppliedCount === undefined && lastFixSkipReason !== undefined && { fixSkipReason: lastFixSkipReason }),
|
|
1117
1928
|
...(latestReviewResult?.commentBody && {
|
|
1118
1929
|
latestReviewComment: {
|
|
@@ -1129,6 +1940,9 @@ export async function runWorkflow(ctx) {
|
|
|
1129
1940
|
throw err;
|
|
1130
1941
|
}
|
|
1131
1942
|
finally {
|
|
1943
|
+
for (const session of skillSessions.values())
|
|
1944
|
+
session.close();
|
|
1945
|
+
skillSessions.clear();
|
|
1132
1946
|
if (pushedShasNeedingRelease.length > 0 || fixPushedShaRequiresRecheck !== null) {
|
|
1133
1947
|
const lockOctokit = createGithubClient(token);
|
|
1134
1948
|
const outcome = workflowFailed ? 'failure' : 'success';
|
|
@@ -1180,7 +1994,11 @@ export async function runWorkflow(ctx) {
|
|
|
1180
1994
|
failedStep,
|
|
1181
1995
|
round: ctx.round,
|
|
1182
1996
|
trigger: ctx.trigger,
|
|
1183
|
-
|
|
1997
|
+
// The tier that actually ran, not the configured one and not the base
|
|
1998
|
+
// class tier — under smart mode all three differ, and telemetry naming the
|
|
1999
|
+
// wrong one is the same class of problem as a comment citing a tier that
|
|
2000
|
+
// never reached the vendor.
|
|
2001
|
+
qualityTier: quality.tier,
|
|
1184
2002
|
}));
|
|
1185
2003
|
}
|
|
1186
2004
|
}
|