forge-workflow 0.0.9 → 0.1.0-beta.2
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/.claude/rules/{greptile-review-process.md → review-process.md} +56 -41
- package/.claude/scripts/{greptile-resolve.sh → review-resolve.sh} +13 -3
- package/.cursor/rules/permissions-guidance.mdc +2 -2
- package/.forge/hooks/check-tdd.js +3 -0
- package/.forge/hooks/forge-native-hook.js +245 -0
- package/.forge/protected-paths.yaml +157 -0
- package/AGENTS.md +151 -61
- package/CHANGELOG.md +681 -0
- package/CLAUDE.md +9 -106
- package/QUICKSTART.md +171 -0
- package/README.md +271 -363
- package/bin/forge-cmd.js +120 -9
- package/bin/forge-preflight.js +26 -5
- package/bin/forge.js +466 -489
- package/docs/INDEX.md +93 -0
- package/docs/PROJECT_DESIGN.md +685 -0
- package/docs/architecture/index.md +66 -0
- package/docs/architecture/notes/README.md +35 -0
- package/docs/architecture/subsystems/README.md +46 -0
- package/docs/{TOOLCHAIN.md → forge/TOOLCHAIN.md} +56 -47
- package/docs/forge/VALIDATION.md +82 -0
- package/docs/{AGENT_INSTALL_PROMPT.md → guides/AGENT_INSTALL_PROMPT.md} +3 -3
- package/docs/guides/BEADS_GITHUB_SYNC.md +32 -0
- package/docs/{ENHANCED_ONBOARDING.md → guides/ENHANCED_ONBOARDING.md} +16 -12
- package/docs/guides/GREPTILE_SETUP.md +46 -0
- package/docs/guides/MANUAL_REVIEW_GUIDE.md +58 -0
- package/docs/guides/MIGRATION.md +56 -0
- package/docs/guides/SETUP.md +118 -0
- package/docs/guides/SUPPORT.md +185 -0
- package/docs/guides/WORKFLOW_TEMPLATES.md +74 -0
- package/docs/guides/memory-backends.md +183 -0
- package/docs/reference/ADAPTERS.md +128 -0
- package/docs/reference/AGENT_SKILL_PARITY.md +175 -0
- package/docs/reference/COMMANDS.md +205 -0
- package/docs/reference/DECISION_DRIFT_GUARDS.md +97 -0
- package/docs/{EXAMPLES.md → reference/EXAMPLES.md} +7 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +135 -0
- package/docs/reference/HERMES_INTEGRATION.md +118 -0
- package/docs/reference/INSIGHTS_RECAP.md +63 -0
- package/docs/reference/INSTALL.md +164 -0
- package/docs/reference/KERNEL_TAXONOMY_VALIDATION.md +161 -0
- package/docs/reference/PROTECTED_PATH_MANIFEST.md +25 -0
- package/docs/reference/RELEASE.md +68 -0
- package/docs/reference/RESEARCH_TEMPLATE.md +292 -0
- package/docs/{ROADMAP.md → reference/ROADMAP.md} +12 -9
- package/docs/reference/SKILLS.md +35 -0
- package/docs/reference/STATUS_BOARD.md +80 -0
- package/docs/reference/TEMPLATES.md +106 -0
- package/docs/reference/TOOLCHAIN.md +658 -0
- package/docs/reference/VALIDATION.md +82 -0
- package/docs/reference/agent-permissions.md +169 -0
- package/docs/reference/beads-to-kernel-migration-ux.md +61 -0
- package/docs/reference/control-plane-guarantees.md +125 -0
- package/docs/reference/dependency-chain.md +331 -0
- package/docs/reference/forge-kernel-issue-command-contract.md +161 -0
- package/docs/reference/forge-kernel-schema.md +72 -0
- package/docs/reference/kernel-conflict-evaluators.md +27 -0
- package/docs/reference/patch-md-format.md +77 -0
- package/docs/reference/protected-state-surfaces.md +59 -0
- package/docs/reference/shepherd.md +115 -0
- package/docs/reference/superpowers-analysis.md +320 -0
- package/docs/reference/superpowers-integration-options.md +404 -0
- package/docs/reference/test-environment.md +519 -0
- package/docs/reference/upgrade-safety.md +59 -0
- package/lefthook.yml +18 -0
- package/lib/adapter-cli.js +307 -0
- package/lib/adapters/beads-issue-adapter.js +127 -0
- package/lib/adapters/beads-kernel-compat.js +1042 -0
- package/lib/adapters/greptile-review-adapter.js +141 -0
- package/lib/adapters/kernel-issue-adapter.js +101 -0
- package/lib/adapters/pr-state-adapter.js +484 -0
- package/lib/adoption-profiles.js +126 -0
- package/lib/agents/README.md +2 -6
- package/lib/agents/claude.plugin.json +3 -8
- package/lib/agents/codex.plugin.json +9 -1
- package/lib/agents/cursor.plugin.json +2 -6
- package/lib/agents/hermes.plugin.json +22 -0
- package/lib/agents-config.js +39 -1236
- package/lib/audit-evidence.js +282 -0
- package/lib/beads-setup.js +225 -28
- package/lib/beads-sync-scaffold.js +36 -107
- package/lib/codex-skills.js +51 -1
- package/lib/commands/_issue.js +744 -70
- package/lib/commands/_manifest.js +91 -0
- package/lib/commands/_registry.js +85 -34
- package/lib/commands/_resolve-command-opts.js +261 -0
- package/lib/commands/_serve-security.js +270 -0
- package/lib/commands/adapter.js +12 -0
- package/lib/commands/add.js +118 -0
- package/lib/commands/audit.js +70 -0
- package/lib/commands/blocked.js +5 -0
- package/lib/commands/board.js +64 -0
- package/lib/commands/claim.js +21 -2
- package/lib/commands/claims.js +7 -0
- package/lib/commands/clean.js +485 -75
- package/lib/commands/close.js +2 -2
- package/lib/commands/comment.js +5 -0
- package/lib/commands/control.js +148 -0
- package/lib/commands/create.js +2 -2
- package/lib/commands/dev.js +185 -7
- package/lib/commands/doc-gate.js +336 -0
- package/lib/commands/doctor.js +156 -0
- package/lib/commands/explain.js +15 -0
- package/lib/commands/export.js +237 -0
- package/lib/commands/gate.js +192 -0
- package/lib/commands/hooks.js +242 -0
- package/lib/commands/inbox.js +118 -0
- package/lib/commands/init.js +598 -0
- package/lib/commands/insights.js +79 -0
- package/lib/commands/issue.js +12 -1
- package/lib/commands/issues.js +66 -0
- package/lib/commands/lint.js +5 -0
- package/lib/commands/list.js +2 -2
- package/lib/commands/merge.js +312 -0
- package/lib/commands/migrate.js +523 -0
- package/lib/commands/new.js +12 -0
- package/lib/commands/options.js +241 -0
- package/lib/commands/orient.js +13 -0
- package/lib/commands/orphans.js +5 -0
- package/lib/commands/patch.js +67 -0
- package/lib/commands/plan.js +436 -24
- package/lib/commands/preflight.js +211 -0
- package/lib/commands/prime.js +13 -0
- package/lib/commands/push.js +69 -2
- package/lib/commands/ready.js +2 -2
- package/lib/commands/recall.js +116 -0
- package/lib/commands/recap.js +61 -0
- package/lib/commands/recommend.js +22 -2
- package/lib/commands/release.js +91 -0
- package/lib/commands/remember.js +74 -0
- package/lib/commands/role.js +99 -0
- package/lib/commands/serve.js +581 -0
- package/lib/commands/setup.js +851 -979
- package/lib/commands/shepherd.js +436 -0
- package/lib/commands/ship.js +23 -1
- package/lib/commands/show.js +2 -2
- package/lib/commands/stage.js +192 -0
- package/lib/commands/stale.js +5 -0
- package/lib/commands/status.js +329 -11
- package/lib/commands/sync.js +34 -46
- package/lib/commands/team.js +15 -2
- package/lib/commands/test.js +58 -7
- package/lib/commands/update.js +2 -2
- package/lib/commands/upgrade.js +47 -0
- package/lib/commands/validate.js +56 -25
- package/lib/commands/worktree.js +308 -128
- package/lib/config-writer.js +202 -0
- package/lib/control-plane.js +236 -0
- package/lib/core/runtime-graph.js +946 -0
- package/lib/dep-guard/keyword-ripple.js +184 -0
- package/lib/deprecated-sync-cleanup.js +362 -0
- package/lib/detect-agent.js +2 -28
- package/lib/detect-worktree.js +42 -17
- package/lib/doc-gate/declaration.js +177 -0
- package/lib/doc-gate/detect.js +289 -0
- package/lib/doc-gate/gate.js +375 -0
- package/lib/doc-gate/okf-config.js +128 -0
- package/lib/doc-gate/okf.js +429 -0
- package/lib/docs-command.js +1161 -6
- package/lib/forge-issues.js +697 -0
- package/lib/forge-lock.js +262 -0
- package/lib/gate-events.js +193 -0
- package/lib/global-flags.js +74 -0
- package/lib/greptile-match.js +7 -63
- package/lib/harness-capability-matrix.js +380 -0
- package/lib/hook-global-installer.js +347 -0
- package/lib/hook-renderer.js +451 -0
- package/lib/inbox.js +391 -0
- package/lib/insights.js +397 -0
- package/lib/issue-adapter.js +156 -0
- package/lib/issue-backend.js +145 -0
- package/lib/issue-render.js +220 -0
- package/lib/issue-sync/authority.js +100 -0
- package/lib/issue-sync/github-pull.js +184 -0
- package/lib/issue-sync/import-primitives.js +98 -0
- package/lib/issue-sync/legacy-link-bridge.js +436 -0
- package/lib/issue-sync/link-store.js +292 -0
- package/lib/issue-sync/project-github.js +123 -0
- package/lib/issue-sync/reconcile.js +195 -0
- package/lib/issue-sync/schema.js +126 -0
- package/lib/kernel/backing-issue.js +305 -0
- package/lib/kernel/broker.js +1218 -0
- package/lib/kernel/cli-broker-factory.js +130 -0
- package/lib/kernel/conflict-signal.js +82 -0
- package/lib/kernel/evaluators.js +195 -0
- package/lib/kernel/fs-class.js +495 -0
- package/lib/kernel/issue-command-contract.js +559 -0
- package/lib/kernel/issue-id-resolver.js +186 -0
- package/lib/kernel/lease-enforcer.js +158 -0
- package/lib/kernel/migrations.js +333 -0
- package/lib/kernel/planning-buckets-schema.js +109 -0
- package/lib/kernel/projection-jsonl-writer.js +450 -0
- package/lib/kernel/readiness-model.js +329 -0
- package/lib/kernel/schema.js +356 -0
- package/lib/kernel/sqlite-driver.js +2504 -0
- package/lib/kernel/taxonomy-validator.js +394 -0
- package/lib/lefthook-check.js +8 -4
- package/lib/lefthook-wiring.js +413 -0
- package/lib/mcp-config-renderer.js +288 -0
- package/lib/memory/graphiti-mcp.js +106 -0
- package/lib/memory/router.js +387 -0
- package/lib/memory/typed-api.js +102 -0
- package/lib/memory-digest.js +195 -0
- package/lib/merge-rules.js +395 -0
- package/lib/migrate-dry-run.js +466 -0
- package/lib/orientation.js +863 -0
- package/lib/package-manager-remediation.js +103 -0
- package/lib/package-root.js +381 -0
- package/lib/patch-intent.js +890 -0
- package/lib/plugin-catalog.js +3 -4
- package/lib/plugin-manager.js +0 -5
- package/lib/pr-bundle.js +186 -0
- package/lib/pr-monitor/differ.js +195 -0
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/gather.js +124 -0
- package/lib/pr-monitor/journal.js +299 -0
- package/lib/pr-monitor/monitor.js +146 -0
- package/lib/pr-monitor/render-sticky.js +157 -0
- package/lib/pr-monitor/watch-lifecycle.js +95 -0
- package/lib/pr-monitor/watch.js +247 -0
- package/lib/pr-pull.js +1273 -0
- package/lib/pr-shepherd.js +494 -0
- package/lib/pr-state-validator.js +59 -0
- package/lib/preflight/gates.js +237 -0
- package/lib/preflight/runner.js +116 -0
- package/lib/project-discovery.js +0 -53
- package/lib/project-memory.js +166 -0
- package/lib/protected-path-manifest.js +281 -0
- package/lib/protected-state-surfaces.js +387 -0
- package/lib/release-readiness.js +2089 -0
- package/lib/reset.js +59 -45
- package/lib/review-adapter.js +68 -0
- package/lib/rules-sync.js +260 -0
- package/lib/runtime-health.js +332 -23
- package/lib/safety-config-renderer.js +268 -0
- package/lib/setup-action-log.js +1 -7
- package/lib/setup.js +27 -65
- package/lib/shell-utils.js +76 -6
- package/lib/skills-sync.js +330 -0
- package/lib/smart-status/conflicts.js +205 -0
- package/lib/smart-status/scoring.js +191 -0
- package/lib/status/beads-snapshot.js +145 -0
- package/lib/status/presenter.js +216 -0
- package/lib/status/snapshot.js +186 -0
- package/lib/sync-backend.js +202 -0
- package/lib/untrusted-content.js +52 -0
- package/lib/upgrade-safety.js +199 -0
- package/lib/workflow/enforce-stage.js +298 -47
- package/lib/workflow/stage-transition.js +115 -0
- package/lib/workflow/stages.js +30 -6
- package/lib/workflow/state-manager.js +159 -14
- package/lib/workflow/state.js +23 -1
- package/lib/workflow-profiles.js +17 -5
- package/package.json +46 -36
- package/rules/documentation.md +19 -0
- package/rules/kernel-tracking.md +26 -0
- package/rules/security.md +22 -0
- package/rules/tdd.md +20 -0
- package/rules/workflow.md +27 -0
- package/scripts/auto-backing-issue.js +47 -0
- package/scripts/beads-context.sh +165 -22
- package/scripts/beads-migrate-to-dolt.sh +7 -0
- package/scripts/beads-upgrade-smoke.sh +284 -0
- package/scripts/behavioral-judge.sh +115 -11
- package/scripts/benchmark.js +349 -63
- package/scripts/bootstrap-windows-tools.sh +78 -0
- package/scripts/branch-protection.js +2 -3
- package/scripts/check-agents.js +34 -137
- package/scripts/commitlint.js +3 -1
- package/scripts/conflict-detect.sh +3 -0
- package/scripts/dep-guard-analyze.js +52 -17
- package/scripts/dep-guard-keyword-ripple.js +29 -0
- package/scripts/dep-guard-render-review.js +86 -0
- package/scripts/dep-guard.sh +64 -232
- package/scripts/file-index.sh +3 -0
- package/scripts/forge-team/lib/claim.sh +34 -18
- package/scripts/forge-team/lib/dashboard.sh +61 -86
- package/scripts/forge-team/lib/epic.sh +99 -263
- package/scripts/forge-team/lib/hooks.sh +26 -28
- package/scripts/forge-team/lib/identity.sh +4 -4
- package/scripts/forge-team/lib/sync-github.sh +144 -47
- package/scripts/forge-team/lib/verify.sh +93 -83
- package/scripts/forge-team/lib/workload.sh +41 -65
- package/scripts/forge-team/tests/claim.test.sh +25 -19
- package/scripts/forge-team/tests/dashboard.test.sh +31 -46
- package/scripts/forge-team/tests/epic.test.sh +52 -71
- package/scripts/forge-team/tests/hooks.test.sh +38 -50
- package/scripts/forge-team/tests/identity.test.sh +3 -3
- package/scripts/forge-team/tests/integration.test.sh +44 -66
- package/scripts/forge-team/tests/sync-github.test.sh +183 -79
- package/scripts/forge-team/tests/verify.test.sh +37 -46
- package/scripts/forge-team/tests/workflow-integration.test.sh +4 -4
- package/scripts/forge-team/tests/workload.test.sh +32 -66
- package/scripts/gen-command-manifest.js +153 -0
- package/scripts/gen-embedded-assets.mjs +129 -0
- package/scripts/install.ps1 +139 -0
- package/scripts/install.sh +268 -0
- package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
- package/scripts/lib/release-asset.mjs +84 -0
- package/scripts/parity-check.mjs +145 -0
- package/scripts/parity-check.test.mjs +58 -0
- package/scripts/pin-agentic-workflow-images.js +112 -0
- package/scripts/pr-coordinator.sh +3 -0
- package/scripts/preflight-sonar.eslint.config.mjs +44 -0
- package/scripts/preflight.sh +108 -0
- package/scripts/protected-state-check.js +104 -0
- package/scripts/smart-status-score.js +31 -0
- package/scripts/smart-status-sessions.js +51 -0
- package/scripts/smart-status.sh +117 -369
- package/scripts/spikes/config-race-bench.js +111 -0
- package/scripts/spikes/harness-capability-matrix.js +13 -0
- package/scripts/spikes/patch-anchor-stability-bench.js +125 -0
- package/scripts/spikes/protected-path-manifest.js +20 -0
- package/scripts/spikes/skill-auto-invoke-parity.js +292 -0
- package/scripts/sync-agent-skills.js +62 -0
- package/scripts/sync-agentic-workflow.js +48 -0
- package/scripts/sync-utils.sh +3 -0
- package/scripts/test-ci-shard.js +251 -0
- package/scripts/test-dashboard.js +188 -52
- package/scripts/test-full-suite.js +186 -0
- package/scripts/test-profile.js +278 -0
- package/scripts/test.js +302 -28
- package/scripts/validate.js +143 -0
- package/scripts/validate.sh +18 -1
- package/skills/claim-safety/SKILL.md +102 -0
- package/skills/claim-safety/evals/evals.json +46 -0
- package/{.github/prompts/dev.prompt.md → skills/dev/SKILL.md} +46 -52
- package/skills/dev/evals/evals.json +50 -0
- package/skills/hermes-forge/SKILL.md +185 -0
- package/skills/hermes-forge/evals/evals.json +46 -0
- package/skills/issue-basics/SKILL.md +111 -0
- package/skills/issue-basics/evals/evals.json +46 -0
- package/skills/kernel/SKILL.md +166 -0
- package/skills/kernel/evals/evals.json +50 -0
- package/skills/memory/SKILL.md +102 -0
- package/skills/parallel-deep-research/SKILL.md +14 -11
- package/skills/parallel-deep-research/evals/evals.json +11 -27
- package/{.github/prompts/plan.prompt.md → skills/plan/SKILL.md} +134 -159
- package/skills/plan/evals/evals.json +42 -0
- package/skills/research/SKILL.md +195 -0
- package/skills/research/evals/evals.json +42 -0
- package/{.github/prompts/review.prompt.md → skills/review/SKILL.md} +98 -62
- package/skills/review/evals/evals.json +42 -0
- package/skills/rollback/SKILL.md +110 -0
- package/skills/rollback/evals/evals.json +46 -0
- package/skills/rollback/references/methods.md +204 -0
- package/{.cursor/commands/rollback.md → skills/rollback/references/workflow-integration.md} +10 -284
- package/skills/shepherd/SKILL.md +66 -0
- package/skills/shepherd/evals/evals.json +42 -0
- package/skills/ship/SKILL.md +251 -0
- package/skills/ship/evals/evals.json +42 -0
- package/skills/smith/SKILL.md +142 -0
- package/skills/smith/evals/evals.json +46 -0
- package/skills/smith/references/autonomy-and-gates.md +94 -0
- package/{.github/prompts/sonarcloud.prompt.md → skills/sonarcloud/SKILL.md} +14 -3
- package/skills/sonarcloud/evals/evals.json +46 -0
- package/skills/sonarcloud-analysis/SKILL.md +18 -13
- package/skills/sonarcloud-analysis/evals/evals.json +11 -15
- package/skills/status/SKILL.md +102 -0
- package/skills/status/evals/evals.json +50 -0
- package/skills/triage-ready/SKILL.md +121 -0
- package/skills/triage-ready/evals/evals.json +42 -0
- package/{.github/prompts/validate.prompt.md → skills/validate/SKILL.md} +52 -29
- package/skills/validate/evals/evals.json +42 -0
- package/skills/verify/SKILL.md +299 -0
- package/skills/verify/evals/evals.json +50 -0
- package/.claude/commands/dev.md +0 -345
- package/.claude/commands/plan.md +0 -566
- package/.claude/commands/premerge.md +0 -186
- package/.claude/commands/research.md +0 -42
- package/.claude/commands/review.md +0 -451
- package/.claude/commands/rollback.md +0 -721
- package/.claude/commands/ship.md +0 -213
- package/.claude/commands/sonarcloud.md +0 -152
- package/.claude/commands/status.md +0 -90
- package/.claude/commands/validate.md +0 -288
- package/.claude/commands/verify.md +0 -269
- package/.claude/rules/workflow.md +0 -121
- package/.cline/workflows/dev.md +0 -342
- package/.cline/workflows/plan.md +0 -563
- package/.cline/workflows/premerge.md +0 -183
- package/.cline/workflows/research.md +0 -39
- package/.cline/workflows/review.md +0 -448
- package/.cline/workflows/rollback.md +0 -718
- package/.cline/workflows/ship.md +0 -210
- package/.cline/workflows/sonarcloud.md +0 -146
- package/.cline/workflows/status.md +0 -87
- package/.cline/workflows/validate.md +0 -285
- package/.cline/workflows/verify.md +0 -266
- package/.codex/config.toml +0 -11
- package/.codex/skills/dev/SKILL.md +0 -345
- package/.codex/skills/plan/SKILL.md +0 -566
- package/.codex/skills/premerge/SKILL.md +0 -186
- package/.codex/skills/research/SKILL.md +0 -42
- package/.codex/skills/review/SKILL.md +0 -451
- package/.codex/skills/rollback/SKILL.md +0 -721
- package/.codex/skills/ship/SKILL.md +0 -213
- package/.codex/skills/sonarcloud/SKILL.md +0 -149
- package/.codex/skills/status/SKILL.md +0 -90
- package/.codex/skills/validate/SKILL.md +0 -288
- package/.codex/skills/verify/SKILL.md +0 -269
- package/.cursor/commands/dev.md +0 -342
- package/.cursor/commands/plan.md +0 -563
- package/.cursor/commands/premerge.md +0 -183
- package/.cursor/commands/research.md +0 -39
- package/.cursor/commands/review.md +0 -448
- package/.cursor/commands/ship.md +0 -210
- package/.cursor/commands/sonarcloud.md +0 -146
- package/.cursor/commands/status.md +0 -87
- package/.cursor/commands/validate.md +0 -285
- package/.cursor/commands/verify.md +0 -266
- package/.cursorrules +0 -149
- package/.github/prompts/premerge.prompt.md +0 -188
- package/.github/prompts/research.prompt.md +0 -44
- package/.github/prompts/rollback.prompt.md +0 -723
- package/.github/prompts/ship.prompt.md +0 -215
- package/.github/prompts/status.prompt.md +0 -92
- package/.github/prompts/verify.prompt.md +0 -271
- package/.github/workflows/beads-to-github.yml +0 -56
- package/.github/workflows/github-to-beads.yml +0 -97
- package/.kilocode/workflows/dev.md +0 -346
- package/.kilocode/workflows/plan.md +0 -567
- package/.kilocode/workflows/premerge.md +0 -187
- package/.kilocode/workflows/research.md +0 -43
- package/.kilocode/workflows/review.md +0 -452
- package/.kilocode/workflows/rollback.md +0 -722
- package/.kilocode/workflows/ship.md +0 -214
- package/.kilocode/workflows/sonarcloud.md +0 -150
- package/.kilocode/workflows/status.md +0 -91
- package/.kilocode/workflows/validate.md +0 -289
- package/.kilocode/workflows/verify.md +0 -270
- package/.opencode/commands/dev.md +0 -345
- package/.opencode/commands/plan.md +0 -566
- package/.opencode/commands/premerge.md +0 -186
- package/.opencode/commands/research.md +0 -42
- package/.opencode/commands/review.md +0 -451
- package/.opencode/commands/rollback.md +0 -721
- package/.opencode/commands/ship.md +0 -213
- package/.opencode/commands/sonarcloud.md +0 -149
- package/.opencode/commands/status.md +0 -90
- package/.opencode/commands/validate.md +0 -288
- package/.opencode/commands/verify.md +0 -269
- package/.roo/commands/dev.md +0 -346
- package/.roo/commands/plan.md +0 -567
- package/.roo/commands/premerge.md +0 -187
- package/.roo/commands/research.md +0 -43
- package/.roo/commands/review.md +0 -452
- package/.roo/commands/rollback.md +0 -722
- package/.roo/commands/ship.md +0 -214
- package/.roo/commands/sonarcloud.md +0 -150
- package/.roo/commands/status.md +0 -91
- package/.roo/commands/validate.md +0 -289
- package/.roo/commands/verify.md +0 -270
- package/docs/BEADS_GITHUB_SYNC.md +0 -255
- package/docs/GREPTILE_SETUP.md +0 -400
- package/docs/MANUAL_REVIEW_GUIDE.md +0 -106
- package/docs/SETUP.md +0 -663
- package/docs/VALIDATION.md +0 -363
- package/lib/agents/cline.plugin.json +0 -29
- package/lib/agents/copilot.plugin.json +0 -24
- package/lib/agents/kilocode.plugin.json +0 -22
- package/lib/agents/opencode.plugin.json +0 -23
- package/lib/agents/roo.plugin.json +0 -30
- package/lib/beads-health-check.js +0 -143
- package/lib/commands/commands-reset.js +0 -147
- package/opencode.json +0 -67
- package/scripts/beads-context.test.js +0 -567
- package/scripts/github-beads-sync/comment.mjs +0 -64
- package/scripts/github-beads-sync/config.mjs +0 -148
- package/scripts/github-beads-sync/github-api.mjs +0 -131
- package/scripts/github-beads-sync/index.mjs +0 -332
- package/scripts/github-beads-sync/label-mapper.mjs +0 -54
- package/scripts/github-beads-sync/mapping.mjs +0 -78
- package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
- package/scripts/github-beads-sync/reverse-sync.mjs +0 -138
- package/scripts/github-beads-sync/run-bd.mjs +0 -161
- package/scripts/github-beads-sync/sanitize.mjs +0 -121
- package/scripts/github-beads-sync.config.json +0 -26
- package/scripts/sync-commands.js +0 -600
package/lib/pr-pull.js
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR pull-signal — the "extract WHY, hand back ONE compact fix-payload" half of
|
|
5
|
+
* the shepherd (issue 33e1bbd3).
|
|
6
|
+
*
|
|
7
|
+
* `forge shepherd <pr>` decides a STATE (MERGE_READY/PENDING/ESCALATE...). It
|
|
8
|
+
* does not tell an agent *why* a check failed or *what* review feedback to fix.
|
|
9
|
+
* Without this, an agent must manually run `gh pr checks`, `gh run view
|
|
10
|
+
* --log-failed`, grep the logs, and GraphQL-query threads — token-heavy and slow.
|
|
11
|
+
*
|
|
12
|
+
* `gatherPullSignal` does ALL of that IN CODE and returns one bounded payload:
|
|
13
|
+
* - `failures[]` — per FAILED check: name, conclusion, jobUrl, the ACTUAL
|
|
14
|
+
* failure lines pulled from that job's log (not the whole log), and
|
|
15
|
+
* `alsoFailedOn` (identical excerpts across matrix jobs collapse to one).
|
|
16
|
+
* - `reviewThreads[]` — unresolved, non-outdated threads that need action,
|
|
17
|
+
* INCLUDING review bots (CodeRabbit etc.) because those comments ARE the
|
|
18
|
+
* fixes — mapped to `{ file, line, author, body, threadId, commentId }`.
|
|
19
|
+
* - `state` + a one-line `summary` from the existing decision pass.
|
|
20
|
+
*
|
|
21
|
+
* It NEVER merges and NEVER resolves threads — it only reads and extracts. The
|
|
22
|
+
* state machine (lib/pr-shepherd.js) is untouched; this module composes over it.
|
|
23
|
+
*
|
|
24
|
+
* All `gh` I/O goes through an INJECTABLE runner (`runGh`) and a validated
|
|
25
|
+
* pr-state adapter, so unit tests exercise the extraction/dedupe/shaping logic
|
|
26
|
+
* against fixtures without ever touching real GitHub.
|
|
27
|
+
*
|
|
28
|
+
* @module pr-pull
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { isFailed, isGreen, runShepherdPass } = require('./pr-shepherd');
|
|
32
|
+
const { fenceUntrusted } = require('./untrusted-content');
|
|
33
|
+
|
|
34
|
+
/** Token caps that keep the payload bounded regardless of PR size. */
|
|
35
|
+
const DEFAULT_MAX_FAILURES = 10;
|
|
36
|
+
const DEFAULT_MAX_THREADS = 20;
|
|
37
|
+
const DEFAULT_MAX_EXCERPT_LINES = 30;
|
|
38
|
+
// Fetch a few more logs than we ultimately show, so matrix duplicates can
|
|
39
|
+
// collapse (via dedupe) BEFORE the maxFailures slice — otherwise N identical
|
|
40
|
+
// matrix failures would eat the whole failure budget and hide distinct ones.
|
|
41
|
+
const LOG_FETCH_MULTIPLIER = 3;
|
|
42
|
+
// Merge cannot be declared clean until activity has settled for this long — a
|
|
43
|
+
// re-review or a late reviewer comment inside the window means the PR is still
|
|
44
|
+
// in flight. Mirrors the merge-rules `settle_min` idea (default 10 minutes).
|
|
45
|
+
const DEFAULT_SETTLE_WINDOW_MS = 600000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bot logins whose review threads ARE actionable fixes (their comments tell you
|
|
49
|
+
* what to change). Distinct from pure-automation bots (github-actions, codecov,
|
|
50
|
+
* dependabot) whose threads are noise, not review feedback.
|
|
51
|
+
*/
|
|
52
|
+
const REVIEW_BOT_LOGINS = new Set([
|
|
53
|
+
'coderabbitai', 'coderabbitai[bot]',
|
|
54
|
+
'greptile-apps', 'greptile-apps[bot]',
|
|
55
|
+
'qodo-merge-pro', 'qodo-merge-pro[bot]',
|
|
56
|
+
'sonarqubecloud', 'sonarqubecloud[bot]',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Pure-automation bots whose review THREADS are never review feedback. NOTE:
|
|
60
|
+
* codecov lives here for THREAD classification (its inline threads are noise),
|
|
61
|
+
* but its plain status COMMENT is still scanned for a failure signal by the
|
|
62
|
+
* bot-status scanner below — the two paths are deliberately independent so a
|
|
63
|
+
* failure-signalling status comment is never dropped as "automation noise". */
|
|
64
|
+
const AUTOMATION_BOT_LOGINS = new Set([
|
|
65
|
+
'github-actions', 'github-actions[bot]',
|
|
66
|
+
'codecov', 'codecov[bot]',
|
|
67
|
+
'dependabot', 'dependabot[bot]',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Status/deploy/quality bots that report FAILURE by posting a plain PR ISSUE
|
|
72
|
+
* comment (a Quality-Gate/Deployment/coverage summary) rather than — or in
|
|
73
|
+
* addition to — a check-run or commit-status. Their latest comment is scanned
|
|
74
|
+
* for a failure signal by `buildBotStatusBlockers`. This is the FALLBACK path
|
|
75
|
+
* for bots that only comment; the structured statusCheckRollup signal (handled
|
|
76
|
+
* by classifyRequiredChecks) stays the primary, reliable catch.
|
|
77
|
+
*/
|
|
78
|
+
const STATUS_BOT_LOGINS = new Set([
|
|
79
|
+
'vercel', 'vercel[bot]',
|
|
80
|
+
'netlify', 'netlify[bot]',
|
|
81
|
+
'sonarqubecloud', 'sonarqubecloud[bot]',
|
|
82
|
+
'codecov', 'codecov[bot]', 'codecov-commenter',
|
|
83
|
+
'cloudflare-pages', 'cloudflare-pages[bot]',
|
|
84
|
+
'cloudflare-workers-and-pages', 'cloudflare-workers-and-pages[bot]',
|
|
85
|
+
'render', 'render[bot]',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
// A line in a bot status comment that signals FAILURE / not-ready: an explicit
|
|
89
|
+
// failed quality gate, a failed/errored deployment, dropped coverage, or a
|
|
90
|
+
// failure glyph. Kept line-scoped (matched per line) so an unrelated word in a
|
|
91
|
+
// success comment ("0 failed") is unlikely to trip it, and so the matched line
|
|
92
|
+
// itself becomes the human-readable summary.
|
|
93
|
+
// Split across several smaller alternations (grouped by theme) rather than one
|
|
94
|
+
// giant regex — each stays simple to read and none is individually over-complex.
|
|
95
|
+
// `botFailureSummary` matches a line against ANY of them.
|
|
96
|
+
const BOT_STATUS_FAILURE_PATTERNS = [
|
|
97
|
+
/quality gate failed|failed the quality gate/i,
|
|
98
|
+
/deployment (?:has )?failed|deploy(?:ment)? (?:error|errored)|failed to deploy/i,
|
|
99
|
+
/build failed|coverage (?:decreased|dropped|declined|reduced)|patch coverage[^\n]*\bfail/i,
|
|
100
|
+
/❌|✖|✗|:x:|:no_entry(?:_sign)?:|⛔/,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Signals that a log line is part of the actual failure (test framework "fail"
|
|
104
|
+
// markers, assertion diffs, error prose) rather than passing/progress noise.
|
|
105
|
+
const FAILURE_SIGNAL = /\(fail\)|✗|✘|✖|×|\bFAIL(?:ED|URE)?\b|\bError:|\berror:|AssertionError|Assertion failed|expect\(|Expected:|Received:|^\s*not ok\b|npm ERR!/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Strip the `gh run view --log[-failed]` prefix from a line.
|
|
109
|
+
*
|
|
110
|
+
* gh emits `jobName\tstepName\t<ISO-timestamp> content` (and sometimes a bare
|
|
111
|
+
* leading timestamp). Removing everything up to and including the timestamp
|
|
112
|
+
* leaves just the content — which is what makes two matrix jobs' excerpts
|
|
113
|
+
* byte-identical (the job-name/timestamp prefix is the only thing that differs).
|
|
114
|
+
*
|
|
115
|
+
* @param {string} line
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function cleanLogLine(line) {
|
|
119
|
+
const s = String(line).replace(/\r$/, '');
|
|
120
|
+
const m = s.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z\s?/);
|
|
121
|
+
if (m) return s.slice(m.index + m[0].length);
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract the ACTUAL failure lines from a raw job log — the `(fail)` test lines,
|
|
127
|
+
* assertion diffs, and error text — not the whole log. Falls back to the log
|
|
128
|
+
* tail (where the error usually lands) when no failure signal is present.
|
|
129
|
+
* Repeated identical lines collapse; the result is capped at `maxLines`.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} logText
|
|
132
|
+
* @param {{ maxLines?: number }} [opts]
|
|
133
|
+
* @returns {string} newline-joined excerpt
|
|
134
|
+
*/
|
|
135
|
+
function extractFailureExcerpt(logText, opts = {}) {
|
|
136
|
+
const maxLines = opts.maxLines || DEFAULT_MAX_EXCERPT_LINES;
|
|
137
|
+
const cleaned = String(logText || '').split(/\r?\n/).map(cleanLogLine);
|
|
138
|
+
const nonEmpty = cleaned.filter((l) => l.trim());
|
|
139
|
+
const signal = nonEmpty.filter((l) => FAILURE_SIGNAL.test(l));
|
|
140
|
+
const chosen = signal.length > 0 ? signal : nonEmpty.slice(-maxLines);
|
|
141
|
+
|
|
142
|
+
// Collapse duplicate lines (matrix logs repeat the same assertion many times)
|
|
143
|
+
// while preserving first-seen order.
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const unique = [];
|
|
146
|
+
for (const line of chosen) {
|
|
147
|
+
if (seen.has(line)) continue;
|
|
148
|
+
seen.add(line);
|
|
149
|
+
unique.push(line);
|
|
150
|
+
}
|
|
151
|
+
return unique.slice(0, maxLines).join('\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Pull the numeric job id out of an Actions "details" URL
|
|
156
|
+
* (`.../actions/runs/<run>/job/<job>`). Returns null when absent.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} url
|
|
159
|
+
* @returns {string | null}
|
|
160
|
+
*/
|
|
161
|
+
function jobIdFromUrl(url) {
|
|
162
|
+
const m = String(url || '').match(/\/job\/(\d+)/);
|
|
163
|
+
return m ? m[1] : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Collapse identical failure excerpts across matrix jobs into ONE entry, keeping
|
|
168
|
+
* the first job as the representative and recording how many OTHER jobs shared
|
|
169
|
+
* the identical failure in `alsoFailedOn` (0 when unique). Distinct failures
|
|
170
|
+
* with an empty excerpt are NOT merged (an empty excerpt is not evidence of
|
|
171
|
+
* sameness).
|
|
172
|
+
*
|
|
173
|
+
* @param {Array<{name:string,conclusion:string,jobUrl:string,excerpt:string}>} rawFailures
|
|
174
|
+
* @returns {Array<{name:string,conclusion:string,jobUrl:string,excerpt:string,alsoFailedOn:number}>}
|
|
175
|
+
*/
|
|
176
|
+
function dedupeFailures(rawFailures) {
|
|
177
|
+
const groups = new Map();
|
|
178
|
+
const order = [];
|
|
179
|
+
(Array.isArray(rawFailures) ? rawFailures : []).forEach((f, index) => {
|
|
180
|
+
const excerpt = String(f.excerpt || '');
|
|
181
|
+
// Empty excerpts get a per-item key so unrelated "no evidence" failures stay
|
|
182
|
+
// separate instead of collapsing into a single misleading entry.
|
|
183
|
+
const key = excerpt.trim() ? excerpt : `__empty__${index}`;
|
|
184
|
+
if (!groups.has(key)) {
|
|
185
|
+
groups.set(key, { rep: f, count: 0 });
|
|
186
|
+
order.push(key);
|
|
187
|
+
} else {
|
|
188
|
+
groups.get(key).count += 1;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return order.map((key) => {
|
|
192
|
+
const { rep, count } = groups.get(key);
|
|
193
|
+
return {
|
|
194
|
+
name: rep.name,
|
|
195
|
+
conclusion: rep.conclusion,
|
|
196
|
+
jobUrl: rep.jobUrl,
|
|
197
|
+
excerpt: String(rep.excerpt || ''),
|
|
198
|
+
alsoFailedOn: count,
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function commentAuthorClass(author) {
|
|
204
|
+
const a = String(author || '').toLowerCase();
|
|
205
|
+
if (!a) return 'unknown';
|
|
206
|
+
if (REVIEW_BOT_LOGINS.has(a)) return 'review-bot';
|
|
207
|
+
if (AUTOMATION_BOT_LOGINS.has(a)) return 'automation';
|
|
208
|
+
return 'human';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Lowercased login of an issue comment ({author:{login}} or a bare string). */
|
|
212
|
+
function issueCommentLogin(comment) {
|
|
213
|
+
return String(comment.author?.login || comment.author || '').toLowerCase();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Drop the trailing `[bot]` suffix for a readable bot name in the blocker text. */
|
|
217
|
+
function prettyBotName(login) {
|
|
218
|
+
return String(login || '').replace(/\[bot\]$/i, '');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The first line of a bot comment body that signals FAILURE, trimmed and capped —
|
|
223
|
+
* or null when the comment shows no failure signal (i.e. it is healthy/ready, so
|
|
224
|
+
* an earlier failure has been superseded).
|
|
225
|
+
*/
|
|
226
|
+
function botFailureSummary(body) {
|
|
227
|
+
const lines = String(body || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
if (BOT_STATUS_FAILURE_PATTERNS.some((re) => re.test(line))) return line.slice(0, 200);
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Scan plain PR ISSUE comments from known status/deploy/quality bots and surface
|
|
236
|
+
* an actionable blocker for each bot whose LATEST comment signals failure.
|
|
237
|
+
*
|
|
238
|
+
* ACTIONABLE-ONLY + SUPERSESSION: only the newest comment per bot (by
|
|
239
|
+
* `createdAt`, not array order) is examined — a later success comment supersedes
|
|
240
|
+
* an earlier failure, and a bot edit-in-place is reflected in that one comment's
|
|
241
|
+
* final body. Comments from non-status bots and humans are ignored (their
|
|
242
|
+
* feedback flows through the review-thread path, not here). Capped at `cap`.
|
|
243
|
+
*
|
|
244
|
+
* @param {Array<{author:(object|string),body:string,createdAt?:string}>} comments
|
|
245
|
+
* @param {{ cap?: number }} [opts]
|
|
246
|
+
* @returns {Array<{ type: 'bot-status', detail: string }>}
|
|
247
|
+
*/
|
|
248
|
+
function buildBotStatusBlockers(comments, opts = {}) {
|
|
249
|
+
const cap = opts.cap || DEFAULT_MAX_THREADS;
|
|
250
|
+
const latestByBot = new Map();
|
|
251
|
+
for (const c of (Array.isArray(comments) ? comments : [])) {
|
|
252
|
+
const login = issueCommentLogin(c);
|
|
253
|
+
if (!STATUS_BOT_LOGINS.has(login)) continue;
|
|
254
|
+
const ts = Date.parse(c.createdAt || c.updatedAt || '') || 0;
|
|
255
|
+
const prev = latestByBot.get(login);
|
|
256
|
+
// `>=` so a same-timestamp (or timestamp-less) tie keeps the LATER array
|
|
257
|
+
// entry — GitHub returns issue comments oldest-first.
|
|
258
|
+
if (!prev || ts >= prev.ts) latestByBot.set(login, { ts, login, body: String(c.body || '') });
|
|
259
|
+
}
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const { login, body } of latestByBot.values()) {
|
|
262
|
+
const summary = botFailureSummary(body);
|
|
263
|
+
if (!summary) continue; // latest comment is healthy → superseded, not actionable
|
|
264
|
+
out.push({
|
|
265
|
+
type: 'bot-status',
|
|
266
|
+
detail: `${prettyBotName(login)} reports a failing status: ${summary}`,
|
|
267
|
+
});
|
|
268
|
+
if (out.length >= cap) break;
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Filter review threads to the ones that need action and map them to the compact
|
|
275
|
+
* fix shape. Actionable = unresolved AND not outdated AND authored (in at least
|
|
276
|
+
* one comment) by a human OR a REVIEW bot (CodeRabbit et al.), excluding the
|
|
277
|
+
* shepherd's own login and pure-automation bots. Capped at `maxThreads`.
|
|
278
|
+
*
|
|
279
|
+
* @param {object[]} threads - from adapter.readComments
|
|
280
|
+
* @param {string} [self] - the shepherd's own login (excluded to avoid self-wake)
|
|
281
|
+
* @param {{ maxThreads?: number }} [opts]
|
|
282
|
+
* @returns {Array<{file:string|null,line:number|null,author:string,body:string,threadId:string|null,commentId:string|null}>}
|
|
283
|
+
*/
|
|
284
|
+
function buildReviewThreads(threads, self, opts = {}) {
|
|
285
|
+
const maxThreads = opts.maxThreads || DEFAULT_MAX_THREADS;
|
|
286
|
+
const selfLower = String(self || '').toLowerCase();
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const t of (Array.isArray(threads) ? threads : [])) {
|
|
289
|
+
if (t.isResolved || t.resolved || t.isOutdated || t.outdated) continue;
|
|
290
|
+
const comments = Array.isArray(t.comments) ? t.comments : [];
|
|
291
|
+
// Anchor on the first comment whose author is a human or a review bot and is
|
|
292
|
+
// not the shepherd itself — that comment carries the fix to act on.
|
|
293
|
+
const anchor = comments.find((c) => {
|
|
294
|
+
const author = String((c.author && c.author.login) || c.author || '').toLowerCase();
|
|
295
|
+
if (!author || author === selfLower) return false;
|
|
296
|
+
const cls = commentAuthorClass(author);
|
|
297
|
+
return cls === 'human' || cls === 'review-bot';
|
|
298
|
+
});
|
|
299
|
+
if (!anchor) continue;
|
|
300
|
+
out.push({
|
|
301
|
+
file: t.path || null,
|
|
302
|
+
line: typeof t.line === 'number' ? t.line : null,
|
|
303
|
+
author: String((anchor.author && anchor.author.login) || anchor.author || ''),
|
|
304
|
+
body: String(anchor.body || ''),
|
|
305
|
+
threadId: t.threadId || t.id || null,
|
|
306
|
+
commentId: anchor.commentId || anchor.databaseId || anchor.id || null,
|
|
307
|
+
});
|
|
308
|
+
if (out.length >= maxThreads) break;
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A check whose conclusion is SKIPPED — green-ish by `isGreen`, but for a
|
|
314
|
+
* REQUIRED context a skip means the gate never actually ran to success, so
|
|
315
|
+
* branch protection keeps the PR blocked. Detected separately from real greens. */
|
|
316
|
+
function isSkipped(check) {
|
|
317
|
+
return String(check.conclusion || '').toUpperCase() === 'SKIPPED';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A check still in flight (not green, not failed) — e.g. IN_PROGRESS/QUEUED or
|
|
321
|
+
* a status context with no conclusion yet. */
|
|
322
|
+
function isPending(check) {
|
|
323
|
+
return !isGreen(check) && !isFailed(check);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Classify the branch-protection REQUIRED set against what the PR actually
|
|
328
|
+
* produced — the ONLY reliable way to explain a PR that is BLOCKED while every
|
|
329
|
+
* visible check is green. A required context is:
|
|
330
|
+
* - `missing` — it never reported at all (a workflow that didn't trigger);
|
|
331
|
+
* - `skipped` — every instance resolved SKIPPED (the policy-block cause: a
|
|
332
|
+
* required gate that skipped is NOT a success to branch protection);
|
|
333
|
+
* - `failing` — any instance failed;
|
|
334
|
+
* - `pending` — still running / not yet reported a conclusion.
|
|
335
|
+
* Green required checks are intentionally OMITTED — the payload is actionable-only.
|
|
336
|
+
* Matrix duplicates (same context name reported by multiple jobs) are aggregated
|
|
337
|
+
* with failing > pending > skipped > green precedence.
|
|
338
|
+
*
|
|
339
|
+
* @param {object[]} checks - normalized rollup from readState.
|
|
340
|
+
* @param {string[]|null} requiredSet - branch-protection contexts, or null when unreadable.
|
|
341
|
+
* @returns {{ missing: string[], skipped: string[], pending: string[], failing: string[], unreadable: boolean }}
|
|
342
|
+
*/
|
|
343
|
+
function classifyRequiredChecks(checks, requiredSet) {
|
|
344
|
+
if (!Array.isArray(requiredSet)) {
|
|
345
|
+
return { missing: [], skipped: [], pending: [], failing: [], unreadable: true };
|
|
346
|
+
}
|
|
347
|
+
const byName = new Map();
|
|
348
|
+
for (const c of (Array.isArray(checks) ? checks : [])) {
|
|
349
|
+
const name = c.name || c.context || '';
|
|
350
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
351
|
+
byName.get(name).push(c);
|
|
352
|
+
}
|
|
353
|
+
const missing = []; const skipped = []; const pending = []; const failing = [];
|
|
354
|
+
for (const name of requiredSet) {
|
|
355
|
+
const instances = byName.get(name);
|
|
356
|
+
if (!instances || instances.length === 0) { missing.push(name); continue; }
|
|
357
|
+
if (instances.some(isFailed)) { failing.push(name); continue; }
|
|
358
|
+
if (instances.some(isPending)) { pending.push(name); continue; }
|
|
359
|
+
// All instances are green-ish. If EVERY instance only ever skipped, the
|
|
360
|
+
// required gate did not truly pass → policy block.
|
|
361
|
+
if (instances.every(isSkipped)) { skipped.push(name); continue; }
|
|
362
|
+
// otherwise a genuine success — omitted (actionable-only).
|
|
363
|
+
}
|
|
364
|
+
return { missing, skipped, pending, failing, unreadable: false };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The unique names of checks still pending (any check, not just required),
|
|
369
|
+
* deduped and bounded — surfaced so an agent knows the PR is simply not done yet
|
|
370
|
+
* versus actively broken.
|
|
371
|
+
*/
|
|
372
|
+
function pendingCheckNames(checks, cap = DEFAULT_MAX_THREADS) {
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
for (const c of (Array.isArray(checks) ? checks : [])) {
|
|
375
|
+
if (isPending(c)) seen.add(c.name || c.context || '');
|
|
376
|
+
}
|
|
377
|
+
return [...seen].filter(Boolean).slice(0, cap);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const AND_LIST_CAP = 6;
|
|
381
|
+
|
|
382
|
+
/** Join a list of names for a blocker detail, truncating with a count. */
|
|
383
|
+
function joinNames(names) {
|
|
384
|
+
const list = names.slice(0, AND_LIST_CAP);
|
|
385
|
+
const extra = names.length - list.length;
|
|
386
|
+
return extra > 0 ? `${list.join(', ')} (+${extra} more)` : list.join(', ');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Push `item` onto `arr` only when it is truthy (small helper so the blocker
|
|
390
|
+
* assembly reads as a flat list without repeated `if`/`push` nesting). */
|
|
391
|
+
function pushMaybe(arr, item) {
|
|
392
|
+
if (item) arr.push(item);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The draft blocker, or null when the PR is not a draft. */
|
|
396
|
+
function draftBlocker(draft) {
|
|
397
|
+
return draft
|
|
398
|
+
? { type: 'draft', detail: 'PR is a draft — mark it "Ready for review" before it can merge.' }
|
|
399
|
+
: null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The conflict blocker. A predicted conflict (`conflicts.conflicted === true`)
|
|
404
|
+
* ALWAYS produces a blocker — even when the `files` list is empty/unparseable and
|
|
405
|
+
* `mergeable`/`mergeStateStatus` don't literally say CONFLICTING/DIRTY — so the
|
|
406
|
+
* payload's `conflicts` object and `blockers[]` never disagree. Falls back to the
|
|
407
|
+
* mergeable/status signal when no file-level prediction is available.
|
|
408
|
+
*/
|
|
409
|
+
function conflictBlocker(conflicts, merge, status) {
|
|
410
|
+
if (conflicts && conflicts.conflicted) {
|
|
411
|
+
const files = Array.isArray(conflicts.files) ? conflicts.files : [];
|
|
412
|
+
const detail = files.length > 0
|
|
413
|
+
? `Merge conflict in ${files.length} file(s): ${joinNames(files)} — resolve against base.`
|
|
414
|
+
: 'Merge conflict detected against base — resolve and push.';
|
|
415
|
+
return { type: 'conflict', detail };
|
|
416
|
+
}
|
|
417
|
+
if (merge === 'CONFLICTING' || status === 'DIRTY') {
|
|
418
|
+
return { type: 'conflict', detail: 'Branch conflicts with base (mergeable=CONFLICTING) — rebase/merge base and resolve.' };
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** All required-check blockers (failing / missing / skipped / pending) in
|
|
424
|
+
* most-actionable-first order. Empty when the required set is all green. */
|
|
425
|
+
function requiredCheckBlockers(rc) {
|
|
426
|
+
const out = [];
|
|
427
|
+
if ((rc.failing || []).length > 0) {
|
|
428
|
+
out.push({ type: 'check-failing', detail: `Required check(s) failing: ${joinNames(rc.failing)} — see failures[] for the exact log excerpt.` });
|
|
429
|
+
}
|
|
430
|
+
if ((rc.missing || []).length > 0) {
|
|
431
|
+
out.push({ type: 'check-missing', detail: `Required check(s) never reported: ${joinNames(rc.missing)} — the workflow did not trigger; push a commit or re-run CI.` });
|
|
432
|
+
}
|
|
433
|
+
if ((rc.skipped || []).length > 0) {
|
|
434
|
+
out.push({ type: 'check-skipped', detail: `Required check(s) SKIPPED: ${joinNames(rc.skipped)} — a required gate that skips is NOT a pass to branch protection; it must run to success (this is why an all-green PR can stay BLOCKED).` });
|
|
435
|
+
}
|
|
436
|
+
if ((rc.pending || []).length > 0) {
|
|
437
|
+
out.push({ type: 'check-pending', detail: `Required check(s) still running: ${joinNames(rc.pending)} — wait for them to finish.` });
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The review-decision blocker (changes requested / review required), or null
|
|
443
|
+
* when the decision is APPROVED or not required (actionable-only). */
|
|
444
|
+
function reviewDecisionBlocker(decision) {
|
|
445
|
+
if (decision === 'CHANGES_REQUESTED') {
|
|
446
|
+
return { type: 'changes-requested', detail: 'A reviewer requested changes — address the feedback and re-request review.' };
|
|
447
|
+
}
|
|
448
|
+
if (decision === 'REVIEW_REQUIRED') {
|
|
449
|
+
return { type: 'review-required', detail: 'An approving review is still required before merge.' };
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Last-resort blocker for a PR that is NOT clean yet nothing concrete explained
|
|
456
|
+
* why. Emitted ONLY when no specific blocker fired, so a non-mergeable PR is
|
|
457
|
+
* never silently invisible (the #353 failure mode). Covers, in priority order:
|
|
458
|
+
* - unreadable required checks (a branch-protection 403 must not stay hidden);
|
|
459
|
+
* - UNSTABLE with a failing non-required check;
|
|
460
|
+
* - BLOCKED by branch protection;
|
|
461
|
+
* - any OTHER non-clean, known status (BEHIND, HAS_HOOKS, …) whose cause wasn't
|
|
462
|
+
* otherwise derivable — e.g. BEHIND when the commit count was unavailable.
|
|
463
|
+
* A clean or unknown status yields no fallback.
|
|
464
|
+
*/
|
|
465
|
+
function fallbackBlocker(status, rc, failuresCount) {
|
|
466
|
+
if (rc.unreadable) {
|
|
467
|
+
return { type: 'check-required-unreadable', detail: 'Branch-protection required checks could not be read (e.g. a 403 from the protection API) — cannot confirm the required gates passed; verify branch-protection settings and token permissions.' };
|
|
468
|
+
}
|
|
469
|
+
if (status === 'UNSTABLE') {
|
|
470
|
+
return failuresCount > 0
|
|
471
|
+
? { type: 'unstable', detail: 'A non-required check is failing (mergeStateStatus=UNSTABLE). It does not gate merge but is worth fixing — see failures[].' }
|
|
472
|
+
: null;
|
|
473
|
+
}
|
|
474
|
+
if (status === 'BLOCKED') {
|
|
475
|
+
return { type: 'blocked-unknown', detail: 'Merge is blocked by branch protection (mergeStateStatus=BLOCKED) but no failing check, missing/skipped required check, unresolved thread, or negative review was detected — check required reviews, code-owner approval, or other protection rules.' };
|
|
476
|
+
}
|
|
477
|
+
if (status && status !== 'CLEAN' && status !== 'UNKNOWN') {
|
|
478
|
+
return { type: 'blocked-unknown', detail: `Merge is not clean (mergeStateStatus=${status}) but no specific cause was derivable — update the branch and re-check required gates, reviews, or protection rules.` };
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Compute the ordered, deduped list of concrete merge BLOCKERS — the
|
|
485
|
+
* human-readable WHY behind `mergeStateStatus`. Ordered most-actionable-first so
|
|
486
|
+
* an agent fixes the real gate, not a symptom. Every entry is something to ACT
|
|
487
|
+
* on; nothing that is already satisfied is listed (actionable-only). When the
|
|
488
|
+
* status is non-clean but no specific cause is derivable from the available
|
|
489
|
+
* signals, a single explicit fallback blocker is emitted so the block is never
|
|
490
|
+
* silently invisible (the #353 failure mode). Per-concern logic lives in the
|
|
491
|
+
* small helpers above; this function just orders and assembles them.
|
|
492
|
+
*
|
|
493
|
+
* @param {object} args
|
|
494
|
+
* @returns {Array<{ type: string, detail: string }>}
|
|
495
|
+
*/
|
|
496
|
+
function computeBlockers({
|
|
497
|
+
mergeable,
|
|
498
|
+
mergeStateStatus,
|
|
499
|
+
draft = false,
|
|
500
|
+
reviewDecision = null,
|
|
501
|
+
requiredClass = { missing: [], skipped: [], pending: [], failing: [], unreadable: false },
|
|
502
|
+
botStatusBlockers = [],
|
|
503
|
+
unresolvedThreadCount = 0,
|
|
504
|
+
behind = 0,
|
|
505
|
+
conflicts = null,
|
|
506
|
+
failuresCount = 0,
|
|
507
|
+
}) {
|
|
508
|
+
const status = String(mergeStateStatus || '').toUpperCase();
|
|
509
|
+
const merge = String(mergeable || '').toUpperCase();
|
|
510
|
+
const decision = String(reviewDecision || '').toUpperCase();
|
|
511
|
+
const rc = requiredClass || {};
|
|
512
|
+
const out = [];
|
|
513
|
+
|
|
514
|
+
pushMaybe(out, draftBlocker(draft));
|
|
515
|
+
pushMaybe(out, conflictBlocker(conflicts, merge, status));
|
|
516
|
+
// Required-check blockers, then bot status/deploy/quality comment failures
|
|
517
|
+
// (Vercel/SonarCloud/Codecov...) — as actionable as a failing check, so they
|
|
518
|
+
// slot in right after them. Combined into ONE push.
|
|
519
|
+
out.push(
|
|
520
|
+
...requiredCheckBlockers(rc),
|
|
521
|
+
...(Array.isArray(botStatusBlockers) ? botStatusBlockers : []),
|
|
522
|
+
);
|
|
523
|
+
if (behind > 0) {
|
|
524
|
+
out.push({ type: 'behind', detail: `Branch is ${behind} commit(s) behind base — update/rebase the branch (protection requires branches be up to date).` });
|
|
525
|
+
}
|
|
526
|
+
pushMaybe(out, reviewDecisionBlocker(decision));
|
|
527
|
+
if (unresolvedThreadCount > 0) {
|
|
528
|
+
out.push({ type: 'unresolved-threads', detail: `${unresolvedThreadCount} unresolved review thread(s) must be resolved before merge (see reviewThreads[]).` });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Nothing concrete explained a non-clean status → make the block visible.
|
|
532
|
+
if (out.length === 0) {
|
|
533
|
+
pushMaybe(out, fallbackBlocker(status, rc, failuresCount));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Summary lines for the blockers section (numbered, or a "none" note). */
|
|
540
|
+
function blockerLines(blockers) {
|
|
541
|
+
if (blockers.length === 0) {
|
|
542
|
+
return ['Blockers: none detected (nothing actionable this pass).'];
|
|
543
|
+
}
|
|
544
|
+
const lines = [`Blockers (${blockers.length}):`];
|
|
545
|
+
blockers.forEach((b, i) => lines.push(` ${i + 1}. [${b.type}] ${b.detail}`));
|
|
546
|
+
return lines;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Summary lines for the failing-checks section (empty when none). */
|
|
550
|
+
function failureLines(failures) {
|
|
551
|
+
if (failures.length === 0) return [];
|
|
552
|
+
const lines = [`Failing checks (${failures.length}):`];
|
|
553
|
+
for (const f of failures) {
|
|
554
|
+
const also = f.alsoFailedOn ? ` (+${f.alsoFailedOn} matrix job(s))` : '';
|
|
555
|
+
lines.push(` • ${f.name}${also}`);
|
|
556
|
+
const first = String(f.excerpt || '').split('\n').filter(Boolean)[0];
|
|
557
|
+
// CI-log excerpts are untrusted external text; fence before surfacing to the agent.
|
|
558
|
+
if (first) lines.push(` ${fenceUntrusted(first, { source: 'ci-log' })}`);
|
|
559
|
+
}
|
|
560
|
+
return lines;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Summary lines for the unresolved-review-threads section (empty when none). */
|
|
564
|
+
function threadLines(threads) {
|
|
565
|
+
if (threads.length === 0) return [];
|
|
566
|
+
const lines = [`Unresolved review threads (${threads.length}):`];
|
|
567
|
+
for (const t of threads) {
|
|
568
|
+
const loc = t.file ? `${t.file}${t.line != null ? `:${t.line}` : ''}` : '(general)';
|
|
569
|
+
const firstLine = String(t.body || '').split('\n').filter(Boolean)[0] || '';
|
|
570
|
+
// Review-comment bodies are untrusted external text; fence before surfacing to the agent.
|
|
571
|
+
const fenced = fenceUntrusted(firstLine.slice(0, 120), { source: 'pr-review-comment' });
|
|
572
|
+
lines.push(` • ${loc} — ${t.author}: ${fenced}`);
|
|
573
|
+
}
|
|
574
|
+
return lines;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Render the bounded payload as a compact human-readable summary (the non-JSON
|
|
579
|
+
* view). Actionable-only, so a maintainer reads exactly what to fix. Section
|
|
580
|
+
* construction lives in the small helpers above; this function just orders them.
|
|
581
|
+
*
|
|
582
|
+
* @param {object} payload - a payload produced by buildPullPayload.
|
|
583
|
+
* @returns {string}
|
|
584
|
+
*/
|
|
585
|
+
function renderPullSummary(payload) {
|
|
586
|
+
const p = payload || {};
|
|
587
|
+
const lines = [];
|
|
588
|
+
const merge = `mergeable=${p.mergeable || 'UNKNOWN'}, mergeStateStatus=${p.mergeStateStatus || 'UNKNOWN'}`;
|
|
589
|
+
lines.push(`PR #${p.pr || '?'} — ${p.state || 'UNKNOWN'} (${merge})`);
|
|
590
|
+
if (p.summary) lines.push(p.summary);
|
|
591
|
+
lines.push(...blockerLines(Array.isArray(p.blockers) ? p.blockers : []));
|
|
592
|
+
lines.push(...failureLines(Array.isArray(p.failures) ? p.failures : []));
|
|
593
|
+
lines.push(...threadLines(Array.isArray(p.reviewThreads) ? p.reviewThreads : []));
|
|
594
|
+
return lines.join('\n');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** `{ pr }` (stringified) when a PR number is given, else `{}`. */
|
|
598
|
+
function prField(pr) {
|
|
599
|
+
return pr !== undefined ? { pr: String(pr) } : {};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Surface reviewDecision only when it is a BLOCKER (actionable-only): APPROVED
|
|
603
|
+
* and "not required" ('') are omitted. */
|
|
604
|
+
function reviewDecisionField(reviewDecision) {
|
|
605
|
+
return (reviewDecision === 'CHANGES_REQUESTED' || reviewDecision === 'REVIEW_REQUIRED')
|
|
606
|
+
? { reviewDecision }
|
|
607
|
+
: {};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Actionable-only required-check block: included only when SOMETHING about the
|
|
612
|
+
* required set needs attention (missing/skipped/pending/failing) or it was
|
|
613
|
+
* unreadable. All-green required sets are omitted entirely.
|
|
614
|
+
*/
|
|
615
|
+
function requiredChecksField(requiredChecks) {
|
|
616
|
+
const rc = requiredChecks || {};
|
|
617
|
+
const hasMissing = !!(rc.missing && rc.missing.length);
|
|
618
|
+
const hasSkipped = !!(rc.skipped && rc.skipped.length);
|
|
619
|
+
const hasPending = !!(rc.pending && rc.pending.length);
|
|
620
|
+
const hasFailing = !!(rc.failing && rc.failing.length);
|
|
621
|
+
if (!(hasMissing || hasSkipped || hasPending || hasFailing || rc.unreadable)) return {};
|
|
622
|
+
return {
|
|
623
|
+
requiredChecks: {
|
|
624
|
+
...(hasMissing ? { missing: rc.missing } : {}),
|
|
625
|
+
...(hasSkipped ? { skipped: rc.skipped } : {}),
|
|
626
|
+
...(hasPending ? { pending: rc.pending } : {}),
|
|
627
|
+
...(hasFailing ? { failing: rc.failing } : {}),
|
|
628
|
+
...(rc.unreadable ? { unreadable: true } : {}),
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** `{ conflicts }` when a conflict is predicted, else `{}`. Mirrors the
|
|
634
|
+
* conflict blocker: any `conflicted === true` surfaces here. */
|
|
635
|
+
function conflictsField(conflicts) {
|
|
636
|
+
return (conflicts && conflicts.conflicted)
|
|
637
|
+
? { conflicts: { conflicted: true, files: Array.isArray(conflicts.files) ? conflicts.files : [] } }
|
|
638
|
+
: {};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Assemble the final bounded payload, enforcing every token cap (failures,
|
|
643
|
+
* threads, per-excerpt line count) and flagging truncation so a consumer knows
|
|
644
|
+
* the view was trimmed. Carries the full actionable-only blocker picture:
|
|
645
|
+
* mergeability + WHY (`blockers`), required-check classification, pending checks,
|
|
646
|
+
* behind-base, conflicts, review decision, and draft state. The conditional
|
|
647
|
+
* field construction lives in the small helpers above.
|
|
648
|
+
*
|
|
649
|
+
* @param {object} args
|
|
650
|
+
* @returns {object}
|
|
651
|
+
*/
|
|
652
|
+
function buildPullPayload({
|
|
653
|
+
pr,
|
|
654
|
+
state,
|
|
655
|
+
verdict,
|
|
656
|
+
evidence,
|
|
657
|
+
degraded = [],
|
|
658
|
+
summary,
|
|
659
|
+
reason,
|
|
660
|
+
mergeable = 'UNKNOWN',
|
|
661
|
+
mergeStateStatus = 'UNKNOWN',
|
|
662
|
+
draft = false,
|
|
663
|
+
reviewDecision = null,
|
|
664
|
+
blockers = [],
|
|
665
|
+
requiredChecks = null,
|
|
666
|
+
pendingChecks = [],
|
|
667
|
+
behind = 0,
|
|
668
|
+
conflicts = null,
|
|
669
|
+
failures = [],
|
|
670
|
+
reviewThreads = [],
|
|
671
|
+
maxFailures = DEFAULT_MAX_FAILURES,
|
|
672
|
+
maxThreads = DEFAULT_MAX_THREADS,
|
|
673
|
+
maxExcerptLines = DEFAULT_MAX_EXCERPT_LINES,
|
|
674
|
+
}) {
|
|
675
|
+
const cappedFailures = failures.slice(0, maxFailures).map((f) => ({
|
|
676
|
+
...f,
|
|
677
|
+
excerpt: String(f.excerpt || '').split('\n').slice(0, maxExcerptLines).join('\n'),
|
|
678
|
+
}));
|
|
679
|
+
const cappedThreads = reviewThreads.slice(0, maxThreads);
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
...prField(pr),
|
|
683
|
+
state,
|
|
684
|
+
// `verdict` is the trustworthy, fail-closed merge signal (never false-clean);
|
|
685
|
+
// `state` remains the legacy decision-pass state for back-compat.
|
|
686
|
+
...(verdict ? { verdict } : {}),
|
|
687
|
+
...(evidence ? { evidence } : {}),
|
|
688
|
+
summary,
|
|
689
|
+
...(reason ? { reason } : {}),
|
|
690
|
+
// Surfaced (not swallowed): which reads degraded and why — empty on a clean gather.
|
|
691
|
+
...(degraded && degraded.length ? { degraded } : {}),
|
|
692
|
+
mergeable,
|
|
693
|
+
mergeStateStatus,
|
|
694
|
+
...(draft ? { draft: true } : {}),
|
|
695
|
+
...reviewDecisionField(reviewDecision),
|
|
696
|
+
blockers,
|
|
697
|
+
...requiredChecksField(requiredChecks),
|
|
698
|
+
...(pendingChecks && pendingChecks.length ? { pendingChecks } : {}),
|
|
699
|
+
...(behind > 0 ? { behind } : {}),
|
|
700
|
+
...conflictsField(conflicts),
|
|
701
|
+
failures: cappedFailures,
|
|
702
|
+
reviewThreads: cappedThreads,
|
|
703
|
+
truncated: {
|
|
704
|
+
failures: failures.length > maxFailures,
|
|
705
|
+
reviewThreads: reviewThreads.length > maxThreads,
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Order failed checks so REQUIRED ones are diagnosed first (they gate merge),
|
|
712
|
+
* then bound how many logs we fetch.
|
|
713
|
+
*/
|
|
714
|
+
function orderFailedChecks(checks, requiredSet) {
|
|
715
|
+
const required = Array.isArray(requiredSet) ? requiredSet : [];
|
|
716
|
+
const failed = (Array.isArray(checks) ? checks : []).filter(isFailed);
|
|
717
|
+
return failed
|
|
718
|
+
.map((c, i) => ({ c, i, req: required.includes(c.name) ? 0 : 1 }))
|
|
719
|
+
.sort((a, b) => (a.req - b.req) || (a.i - b.i))
|
|
720
|
+
.map((x) => x.c);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* One-line human summary of the pull signal — leads with the primary (first,
|
|
725
|
+
* most-actionable) blocker so the WHY is legible at a glance.
|
|
726
|
+
*/
|
|
727
|
+
function summarize({ state, failureCount, threadCount, blockers = [] }) {
|
|
728
|
+
const parts = [];
|
|
729
|
+
parts.push(`${failureCount} failing check${failureCount === 1 ? '' : 's'}`);
|
|
730
|
+
parts.push(`${threadCount} review thread${threadCount === 1 ? '' : 's'} to address`);
|
|
731
|
+
const primary = (Array.isArray(blockers) && blockers[0]) ? ` Primary blocker: ${blockers[0].detail}` : '';
|
|
732
|
+
return `${state}: ${parts.join(', ')}.${primary}`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// --- AGNOSTIC ACTOR CLASSIFICATION (by mechanism, not by a hardcoded name
|
|
736
|
+
// list). New/unknown review bots exist that we cannot enumerate; a "known
|
|
737
|
+
// actionable" name list would fail closed the WRONG way (an unknown bot's signal
|
|
738
|
+
// silently ignored → false-clean). So: classify by HOW the signal arrived, and
|
|
739
|
+
// default an unrecognized actor to BLOCKING.
|
|
740
|
+
// - THREADS: any unresolved, non-outdated review thread blocks, author-agnostic.
|
|
741
|
+
// - CHECKS/STATUS: any failing/pending/skipped-required check OR a failing
|
|
742
|
+
// bot-status comment blocks — no name knowledge needed.
|
|
743
|
+
// - DIRECT COMMENTS: a non-human top-level comment newer than the last head
|
|
744
|
+
// push blocks, UNLESS its author is on the small SUPPRESSION allowlist below.
|
|
745
|
+
// The ONLY name list is that suppression allowlist, and it INVERTS the old model:
|
|
746
|
+
// it lists KNOWN-safe-to-suppress status bots; every OTHER bot (known or unknown)
|
|
747
|
+
// is actionable.
|
|
748
|
+
|
|
749
|
+
/** Suppression ALLOWLIST: status/deploy/quality bots whose failing gate already
|
|
750
|
+
* surfaces via a check or the bot-status-comment scanner (BLOCKED-CHECKS), so
|
|
751
|
+
* their plain PR comment must NOT also drive an unresolvable BLOCKED-THREADS.
|
|
752
|
+
* Reuses STATUS_BOT_LOGINS — the same bots buildBotStatusBlockers scans. Every
|
|
753
|
+
* bot NOT on this list is treated as actionable (fail-closed for unknowns). */
|
|
754
|
+
const SUPPRESSED_COMMENT_BOT_LOGINS = STATUS_BOT_LOGINS;
|
|
755
|
+
|
|
756
|
+
/** Detect a NON-HUMAN comment author by MECHANISM: the GraphQL actor type is a
|
|
757
|
+
* Bot, or the login carries the GitHub App `[bot]` suffix. No name list. */
|
|
758
|
+
function isBotAuthor(comment) {
|
|
759
|
+
if (!comment) return false;
|
|
760
|
+
if (String(comment.authorTypename || '') === 'Bot') return true;
|
|
761
|
+
return String(comment.author || '').toLowerCase().endsWith('[bot]');
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** A bot's direct PR comment is ACTIONABLE (can block) unless it is the
|
|
765
|
+
* shepherd's own comment or a suppressed status/deploy/quality bot. Humans do
|
|
766
|
+
* not block via direct comments (they block via threads/reviews). */
|
|
767
|
+
function isActionableBotComment(comment, self) {
|
|
768
|
+
const login = String(comment?.author || '').toLowerCase();
|
|
769
|
+
if (!login || login === String(self || '').toLowerCase()) return false;
|
|
770
|
+
if (!isBotAuthor(comment)) return false;
|
|
771
|
+
return !SUPPRESSED_COMMENT_BOT_LOGINS.has(login);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Count unresolved, non-outdated review threads AUTHOR-AGNOSTICALLY — any such
|
|
775
|
+
* thread blocks regardless of who opened it (human OR any bot, known or not). */
|
|
776
|
+
function countUnresolvedThreads(threads) {
|
|
777
|
+
return (Array.isArray(threads) ? threads : [])
|
|
778
|
+
.filter((t) => !(t.isResolved || t.resolved || t.isOutdated || t.outdated))
|
|
779
|
+
.length;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Parse an ISO timestamp to epoch ms, or null when unparseable. */
|
|
783
|
+
function parseTime(s) {
|
|
784
|
+
const t = Date.parse(String(s || ''));
|
|
785
|
+
return Number.isFinite(t) ? t : null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** The ONLY mergeStateStatus values from which CLEAN-MERGEABLE is reachable.
|
|
789
|
+
* Everything else — UNKNOWN, '', BLOCKED, BEHIND, DIRTY, UNSTABLE, HAS_HOOKS —
|
|
790
|
+
* fails closed (GitHub returns UNKNOWN while recomputing right after a push, and
|
|
791
|
+
* the adapter defaults to UNKNOWN, so a fresh conflict must NOT read clean). */
|
|
792
|
+
const KNOWN_GOOD_MSS = new Set(['CLEAN']);
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Compute the fail-closed merge VERDICT from already-gathered signals. PURE and
|
|
796
|
+
* independently testable. Precedence (top wins):
|
|
797
|
+
* UNKNOWN > BLOCKED-CONFLICT > BEHIND > BLOCKED-CHECKS > BLOCKED-THREADS >
|
|
798
|
+
* REVIEW-PENDING > CLEAN-MERGEABLE
|
|
799
|
+
*
|
|
800
|
+
* Never defaults clean: an unreadable input, an unreadable required set, an
|
|
801
|
+
* unknown head oid, a torn read (head moved mid-gather), a non-explicitly-good
|
|
802
|
+
* merge state, or an unknown head-push time all force a non-clean verdict.
|
|
803
|
+
*
|
|
804
|
+
* @param {object} input
|
|
805
|
+
* @returns {{ verdict: string, evidence: object }}
|
|
806
|
+
*/
|
|
807
|
+
/**
|
|
808
|
+
* Normalize raw verdict input into a `v` context object carrying the parsed
|
|
809
|
+
* signals plus a mutable `evidence` accumulator. Each rank predicate reads from
|
|
810
|
+
* (and annotates) this object; keeping the shape in one place lets computeVerdict
|
|
811
|
+
* stay a thin dispatch loop (SonarCloud S3776 cognitive-complexity).
|
|
812
|
+
*/
|
|
813
|
+
function buildVerdictContext(input) {
|
|
814
|
+
const {
|
|
815
|
+
headOidStart, headOidEnd,
|
|
816
|
+
mergeStateStatus, mergeable, reviewDecision,
|
|
817
|
+
requiredClass = {}, behind = 0, conflicts = null,
|
|
818
|
+
unresolvedThreadCount = 0,
|
|
819
|
+
botStatusBlockerCount = 0,
|
|
820
|
+
botDirectComments = [],
|
|
821
|
+
reviews = [],
|
|
822
|
+
headPushTimeMs = null, headPushKnown = false,
|
|
823
|
+
issueComments = [],
|
|
824
|
+
now = Date.now(), settleWindowMs = DEFAULT_SETTLE_WINDOW_MS,
|
|
825
|
+
unreadable = [],
|
|
826
|
+
} = input || {};
|
|
827
|
+
|
|
828
|
+
const mss = String(mergeStateStatus || '').toUpperCase();
|
|
829
|
+
const rc = requiredClass || {};
|
|
830
|
+
const evidence = {
|
|
831
|
+
headOid: headOidStart || null,
|
|
832
|
+
mergeStateStatus: mss || null,
|
|
833
|
+
unreadable: [...unreadable],
|
|
834
|
+
tornRead: false,
|
|
835
|
+
failingRequired: rc.failing || [],
|
|
836
|
+
missingRequired: rc.missing || [],
|
|
837
|
+
skippedRequired: rc.skipped || [],
|
|
838
|
+
pendingRequired: rc.pending || [],
|
|
839
|
+
botStatusBlockerCount,
|
|
840
|
+
conflict: false,
|
|
841
|
+
behind,
|
|
842
|
+
unresolvedThreadCount,
|
|
843
|
+
changesRequested: false,
|
|
844
|
+
botComments: [],
|
|
845
|
+
staleReviews: [],
|
|
846
|
+
settleRemainingMs: 0,
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
headOidStart, headOidEnd, mss, mergeable, reviewDecision,
|
|
851
|
+
requiredClass: rc, behind, conflicts,
|
|
852
|
+
unresolvedThreadCount, botStatusBlockerCount, botDirectComments,
|
|
853
|
+
reviews, headPushTimeMs, headPushKnown, issueComments,
|
|
854
|
+
now, settleWindowMs, evidence,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// --- Rank predicates. Each takes the verdict context `v`, annotates `v.evidence`
|
|
859
|
+
// as needed, and returns a verdict string when its rank fires or `null` to fall
|
|
860
|
+
// through to the next rank. They run in strict precedence order. ---
|
|
861
|
+
|
|
862
|
+
/** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
|
|
863
|
+
* oid, or a torn read (head moved during the gather). */
|
|
864
|
+
function rankUnknown(v) {
|
|
865
|
+
const torn = Boolean(v.headOidStart) && Boolean(v.headOidEnd) && v.headOidStart !== v.headOidEnd;
|
|
866
|
+
v.evidence.tornRead = torn;
|
|
867
|
+
if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
|
|
868
|
+
v.evidence.unreadable.push('requiredChecks');
|
|
869
|
+
}
|
|
870
|
+
if (!v.headOidStart && !v.evidence.unreadable.includes('headOid')) {
|
|
871
|
+
v.evidence.unreadable.push('headOid');
|
|
872
|
+
}
|
|
873
|
+
return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Rank 2: hard merge conflict. */
|
|
877
|
+
function rankConflict(v) {
|
|
878
|
+
if (v.conflicts?.conflicted
|
|
879
|
+
|| String(v.mergeable || '').toUpperCase() === 'CONFLICTING'
|
|
880
|
+
|| v.mss === 'DIRTY') {
|
|
881
|
+
v.evidence.conflict = true;
|
|
882
|
+
return 'BLOCKED-CONFLICT';
|
|
883
|
+
}
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Rank 3: branch behind base. */
|
|
888
|
+
function rankBehind(v) {
|
|
889
|
+
return (v.mss === 'BEHIND' || v.behind > 0) ? 'BEHIND' : null;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Rank 4: checks — failing/missing/skipped/pending REQUIRED checks, a failing
|
|
893
|
+
* bot-status quality gate (buildBotStatusBlockers), or an UNSTABLE merge state. */
|
|
894
|
+
function rankChecks(v) {
|
|
895
|
+
const e = v.evidence;
|
|
896
|
+
const blocked = e.failingRequired.length || e.missingRequired.length
|
|
897
|
+
|| e.skippedRequired.length || e.pendingRequired.length
|
|
898
|
+
|| v.botStatusBlockerCount > 0 || v.mss === 'UNSTABLE';
|
|
899
|
+
return blocked ? 'BLOCKED-CHECKS' : null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Rank 5: threads — unresolved inline threads (RAW, author-agnostic count), a
|
|
903
|
+
* fresh non-human direct comment (posted AFTER the last head push, anchored to
|
|
904
|
+
* the push not to later agent chatter), or a CHANGES_REQUESTED review decision. */
|
|
905
|
+
function rankThreads(v) {
|
|
906
|
+
const anchor = v.headPushKnown ? v.headPushTimeMs : 0;
|
|
907
|
+
const botComments = v.botDirectComments.filter((c) => {
|
|
908
|
+
const t = parseTime(c.createdAt);
|
|
909
|
+
return t !== null && t > anchor;
|
|
910
|
+
});
|
|
911
|
+
v.evidence.botComments = botComments.map((c) => c.commentId || null).filter(Boolean);
|
|
912
|
+
const changesRequested = String(v.reviewDecision || '').toUpperCase() === 'CHANGES_REQUESTED';
|
|
913
|
+
v.evidence.changesRequested = changesRequested;
|
|
914
|
+
const blocked = v.unresolvedThreadCount > 0 || botComments.length > 0 || changesRequested;
|
|
915
|
+
return blocked ? 'BLOCKED-THREADS' : null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Rank 6: review-pending — an ANY-bot review against an OLDER commit (stale
|
|
919
|
+
* re-review, #365), a REVIEW_REQUIRED decision, or activity that has not settled
|
|
920
|
+
* (window anchored to the head push). */
|
|
921
|
+
function rankReviewPending(v) {
|
|
922
|
+
const staleReviews = v.reviews.filter(
|
|
923
|
+
(r) => isBotAuthor(r) && r.commitOid && r.commitOid !== v.headOidStart,
|
|
924
|
+
);
|
|
925
|
+
v.evidence.staleReviews = staleReviews.map((r) => r.author).filter(Boolean);
|
|
926
|
+
|
|
927
|
+
const anchors = [];
|
|
928
|
+
if (v.headPushKnown && v.headPushTimeMs != null) anchors.push(v.headPushTimeMs);
|
|
929
|
+
for (const r of v.reviews) { const t = parseTime(r.submittedAt); if (t) anchors.push(t); }
|
|
930
|
+
for (const c of v.issueComments) { const t = parseTime(c.createdAt); if (t) anchors.push(t); }
|
|
931
|
+
const lastActivity = anchors.length ? Math.max(...anchors) : null;
|
|
932
|
+
const settleRemaining = lastActivity === null ? 0 : v.settleWindowMs - (v.now - lastActivity);
|
|
933
|
+
v.evidence.settleRemainingMs = Math.max(settleRemaining, 0);
|
|
934
|
+
|
|
935
|
+
const reviewRequired = String(v.reviewDecision || '').toUpperCase() === 'REVIEW_REQUIRED';
|
|
936
|
+
return (staleReviews.length > 0 || reviewRequired || settleRemaining > 0) ? 'REVIEW-PENDING' : null;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Rank 7 (terminal): CLEAN only when the merge state is explicitly good AND the
|
|
940
|
+
* head push time is known & settled — otherwise fail closed to UNKNOWN. */
|
|
941
|
+
function rankClean(v) {
|
|
942
|
+
return (KNOWN_GOOD_MSS.has(v.mss) && v.headPushKnown) ? 'CLEAN-MERGEABLE' : 'UNKNOWN';
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Precedence-ordered rank predicates (top wins). */
|
|
946
|
+
const VERDICT_RANKS = [
|
|
947
|
+
rankUnknown, rankConflict, rankBehind, rankChecks, rankThreads, rankReviewPending,
|
|
948
|
+
];
|
|
949
|
+
|
|
950
|
+
function computeVerdict(input) {
|
|
951
|
+
const v = buildVerdictContext(input);
|
|
952
|
+
for (const rank of VERDICT_RANKS) {
|
|
953
|
+
const verdict = rank(v);
|
|
954
|
+
if (verdict) return { verdict, evidence: v.evidence };
|
|
955
|
+
}
|
|
956
|
+
return { verdict: rankClean(v), evidence: v.evidence };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Run an optional read and SURFACE any failure instead of swallowing it: on
|
|
961
|
+
* throw, record `{ source, error }` into `degraded` (and optionally mark `source`
|
|
962
|
+
* as `unreadable` so the verdict fails closed), then return `fallback`. This is
|
|
963
|
+
* what keeps gatherPullSignal a thin orchestrator with NO empty catch blocks —
|
|
964
|
+
* every failed read stays diagnosable (WHICH read failed and WHY).
|
|
965
|
+
*
|
|
966
|
+
* @param {string} source
|
|
967
|
+
* @param {() => (Promise<*>|*)} fn
|
|
968
|
+
* @param {{ degraded?: object[], unreadable?: string[], fallback?: * }} [opts]
|
|
969
|
+
* @returns {Promise<*>}
|
|
970
|
+
*/
|
|
971
|
+
async function safeRead(source, fn, { degraded, unreadable, fallback } = {}) {
|
|
972
|
+
try {
|
|
973
|
+
return await fn();
|
|
974
|
+
} catch (err) {
|
|
975
|
+
if (degraded) degraded.push({ source, error: (err && err.message) || String(err) });
|
|
976
|
+
if (unreadable) unreadable.push(source);
|
|
977
|
+
return fallback;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Call `adapter[method](args)` when the method exists, else return `fallback`.
|
|
982
|
+
* Keeps the optional-capability ternaries out of the orchestrator. */
|
|
983
|
+
function callIfPresent(adapter, method, args, fallback) {
|
|
984
|
+
return typeof adapter[method] === 'function' ? adapter[method](args) : fallback;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Fetch + extract the failure excerpts for the FAILED checks (required first),
|
|
989
|
+
* matrix-deduped. A per-job log fetch that throws degrades to an empty excerpt
|
|
990
|
+
* but is surfaced in `degraded` (which job's log was unreadable) rather than
|
|
991
|
+
* silently swallowed.
|
|
992
|
+
*/
|
|
993
|
+
function gatherFailureExcerpts(runGh, checks, requiredSet, { maxFailures, maxExcerptLines, degraded }) {
|
|
994
|
+
const failedChecks = orderFailedChecks(checks, requiredSet)
|
|
995
|
+
.slice(0, maxFailures * LOG_FETCH_MULTIPLIER);
|
|
996
|
+
const rawFailures = failedChecks.map((check) => {
|
|
997
|
+
const jobId = jobIdFromUrl(check.detailsUrl);
|
|
998
|
+
let log = '';
|
|
999
|
+
if (jobId) {
|
|
1000
|
+
try {
|
|
1001
|
+
log = runGh(['run', 'view', '--job', jobId, '--log-failed']) || '';
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
// Degrade to an empty excerpt but SURFACE which job's log was unreadable.
|
|
1004
|
+
if (degraded) degraded.push({ source: `log:${jobId}`, error: (err && err.message) || String(err) });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
name: check.name,
|
|
1009
|
+
conclusion: check.conclusion,
|
|
1010
|
+
jobUrl: check.detailsUrl || null,
|
|
1011
|
+
excerpt: extractFailureExcerpt(log, { maxLines: maxExcerptLines }),
|
|
1012
|
+
};
|
|
1013
|
+
});
|
|
1014
|
+
return dedupeFailures(rawFailures);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Gather the complete pull signal for a PR: decision state + why-it-failed
|
|
1019
|
+
* excerpts (matrix-deduped) + the review-thread fix-list. Pure orchestration
|
|
1020
|
+
* over a validated pr-state adapter and an injected `gh` runner — no live
|
|
1021
|
+
* GitHub, no merging, no thread resolution.
|
|
1022
|
+
*
|
|
1023
|
+
* @param {object} ctx
|
|
1024
|
+
* @param {string} ctx.pr
|
|
1025
|
+
* @param {string} ctx.owner
|
|
1026
|
+
* @param {string} ctx.repo
|
|
1027
|
+
* @param {string} ctx.base - base BRANCH name (branch-protection lookup)
|
|
1028
|
+
* @param {string} ctx.baseRef - base REF for divergence (e.g. origin/master)
|
|
1029
|
+
* @param {string} [ctx.cwd]
|
|
1030
|
+
* @param {string} [ctx.self] - shepherd's own login
|
|
1031
|
+
* @param {object} ctx.adapter - validated pr-state adapter
|
|
1032
|
+
* @param {(args: string[]) => string} ctx.runGh - injected `gh` runner (args → stdout)
|
|
1033
|
+
* @param {Function} [ctx.runPass] - decision pass (default runShepherdPass), injectable for tests
|
|
1034
|
+
* @param {number} [ctx.maxFailures]
|
|
1035
|
+
* @param {number} [ctx.maxThreads]
|
|
1036
|
+
* @param {number} [ctx.maxExcerptLines]
|
|
1037
|
+
* @returns {Promise<object>} the bounded pull payload
|
|
1038
|
+
*/
|
|
1039
|
+
async function gatherPrSnapshot(ctx) {
|
|
1040
|
+
const {
|
|
1041
|
+
pr, owner, repo, base, cwd, self, adapter,
|
|
1042
|
+
maxThreads = DEFAULT_MAX_THREADS,
|
|
1043
|
+
} = ctx;
|
|
1044
|
+
|
|
1045
|
+
if (!adapter || typeof adapter.readState !== 'function') {
|
|
1046
|
+
throw new Error('gatherPrSnapshot requires a pr-state adapter with readState');
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// `degraded` records WHICH read failed and WHY (surfaced downstream); `unreadable`
|
|
1050
|
+
// marks verdict-relevant reads so computeVerdict fails closed to UNKNOWN (never
|
|
1051
|
+
// defaults clean on a bad read). Every optional read goes through safeRead.
|
|
1052
|
+
const degraded = [];
|
|
1053
|
+
const unreadable = [];
|
|
1054
|
+
|
|
1055
|
+
// readState is the one REQUIRED read — if it throws, the whole gather fails.
|
|
1056
|
+
const state = await adapter.readState(pr);
|
|
1057
|
+
const requiredSet = await safeRead(
|
|
1058
|
+
'requiredChecks',
|
|
1059
|
+
() => adapter.readRequiredChecks({ owner, repo, base }),
|
|
1060
|
+
{ degraded, fallback: null },
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
// Review threads (author-agnostic; never resolves anything).
|
|
1064
|
+
const threads = await safeRead(
|
|
1065
|
+
'threads',
|
|
1066
|
+
() => callIfPresent(adapter, 'readComments', { owner, repo, pr, cwd }, []),
|
|
1067
|
+
{ degraded, unreadable, fallback: [] },
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
// Fetch the base ref FIRST so divergence/conflicts compare against the CURRENT
|
|
1071
|
+
// origin/<base>, not a stale local ref (audit A6: a stale ref yields a false
|
|
1072
|
+
// behind=0 / false "no conflict"). Fail CLOSED: a failed refresh is verdict-
|
|
1073
|
+
// relevant — record it in `unreadable` (not just `degraded`) so the divergence
|
|
1074
|
+
// and conflict reads that follow cannot produce a clean verdict from a stale
|
|
1075
|
+
// ref; computeVerdict flips to UNKNOWN instead of a false "not behind".
|
|
1076
|
+
await safeRead(
|
|
1077
|
+
'fetchBase',
|
|
1078
|
+
() => callIfPresent(adapter, 'fetchBase', { baseRef: ctx.baseRef, cwd }, null),
|
|
1079
|
+
{ degraded, unreadable, fallback: null },
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
// Branch divergence (behind base → needs an update/rebase).
|
|
1083
|
+
const div = await safeRead(
|
|
1084
|
+
'divergence',
|
|
1085
|
+
() => callIfPresent(adapter, 'readDivergence', { baseRef: ctx.baseRef, cwd }, { behind: 0 }),
|
|
1086
|
+
{ degraded, fallback: { behind: 0 } },
|
|
1087
|
+
);
|
|
1088
|
+
const behind = div.behind || 0;
|
|
1089
|
+
|
|
1090
|
+
// Predicted merge conflicts (which files) — optional adapter capability.
|
|
1091
|
+
const conflicts = await safeRead(
|
|
1092
|
+
'conflicts',
|
|
1093
|
+
() => callIfPresent(adapter, 'detectConflicts', { baseRef: ctx.baseRef, cwd }, null),
|
|
1094
|
+
{ degraded, fallback: null },
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
// Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
|
|
1098
|
+
// summaries) — plain PR issue comments scanned for a failure signal.
|
|
1099
|
+
const issueComments = await safeRead(
|
|
1100
|
+
'issueComments',
|
|
1101
|
+
() => callIfPresent(adapter, 'readIssueComments', { owner, repo, pr, cwd }, []),
|
|
1102
|
+
{ degraded, unreadable, fallback: [] },
|
|
1103
|
+
);
|
|
1104
|
+
const botStatusBlockers = buildBotStatusBlockers(issueComments, { cap: maxThreads });
|
|
1105
|
+
|
|
1106
|
+
// Required-vs-produced classification (missing / skipped / pending / failing) —
|
|
1107
|
+
// the reliable explanation for an all-green-but-BLOCKED PR.
|
|
1108
|
+
const requiredChecks = classifyRequiredChecks(state.checks, requiredSet);
|
|
1109
|
+
const pendingChecks = pendingCheckNames(state.checks, maxThreads);
|
|
1110
|
+
|
|
1111
|
+
// Hoisted once and reused by computeVerdict and downstream consumers.
|
|
1112
|
+
const draft = state.isDraft || state.draft || false;
|
|
1113
|
+
const reviewDecision = state.reviewDecision || null;
|
|
1114
|
+
|
|
1115
|
+
// --- Verdict inputs: review-at-head (#365), head-push time (settle-window
|
|
1116
|
+
// anchor), and the torn-read guard. ---
|
|
1117
|
+
const reviews = await safeRead(
|
|
1118
|
+
'reviews',
|
|
1119
|
+
() => callIfPresent(adapter, 'readReviews', { owner, repo, pr }, []),
|
|
1120
|
+
{ degraded, unreadable, fallback: [] },
|
|
1121
|
+
);
|
|
1122
|
+
const headPushTimeMs = await safeRead(
|
|
1123
|
+
'headPushTime',
|
|
1124
|
+
() => callIfPresent(adapter, 'readHeadCommitTime', { pr }, null),
|
|
1125
|
+
{ degraded, unreadable, fallback: null },
|
|
1126
|
+
);
|
|
1127
|
+
const headPushKnown = headPushTimeMs != null;
|
|
1128
|
+
// Torn-read guard: re-read the head oid at the END of the gather.
|
|
1129
|
+
const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
|
|
1130
|
+
const headOidEnd = endState ? endState.headSha : state.headSha;
|
|
1131
|
+
|
|
1132
|
+
// Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
|
|
1133
|
+
// filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
|
|
1134
|
+
// of the display fix-list so the verdict never drops an unknown-bot signal.
|
|
1135
|
+
const botDirectComments = (Array.isArray(issueComments) ? issueComments : [])
|
|
1136
|
+
.filter((c) => isActionableBotComment(c, self));
|
|
1137
|
+
const unresolvedThreadCount = countUnresolvedThreads(threads);
|
|
1138
|
+
|
|
1139
|
+
const { verdict, evidence } = computeVerdict({
|
|
1140
|
+
headOidStart: state.headSha,
|
|
1141
|
+
headOidEnd,
|
|
1142
|
+
mergeStateStatus: state.mergeStateStatus,
|
|
1143
|
+
mergeable: state.mergeable,
|
|
1144
|
+
reviewDecision,
|
|
1145
|
+
requiredClass: requiredChecks,
|
|
1146
|
+
behind,
|
|
1147
|
+
conflicts,
|
|
1148
|
+
unresolvedThreadCount,
|
|
1149
|
+
botStatusBlockerCount: botStatusBlockers.length,
|
|
1150
|
+
botDirectComments,
|
|
1151
|
+
reviews,
|
|
1152
|
+
headPushTimeMs,
|
|
1153
|
+
headPushKnown,
|
|
1154
|
+
issueComments,
|
|
1155
|
+
self,
|
|
1156
|
+
now: ctx.now,
|
|
1157
|
+
settleWindowMs: ctx.settleWindowMs,
|
|
1158
|
+
unreadable,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
state, requiredSet, threads, behind, conflicts, issueComments,
|
|
1163
|
+
botStatusBlockers, requiredChecks, pendingChecks, draft, reviewDecision,
|
|
1164
|
+
reviews, headPushTimeMs, headPushKnown, headOidEnd,
|
|
1165
|
+
botDirectComments, unresolvedThreadCount, verdict, evidence,
|
|
1166
|
+
degraded, unreadable,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function gatherPullSignal(ctx) {
|
|
1171
|
+
const {
|
|
1172
|
+
pr, self, adapter, runGh,
|
|
1173
|
+
runPass = runShepherdPass,
|
|
1174
|
+
maxFailures = DEFAULT_MAX_FAILURES,
|
|
1175
|
+
maxThreads = DEFAULT_MAX_THREADS,
|
|
1176
|
+
maxExcerptLines = DEFAULT_MAX_EXCERPT_LINES,
|
|
1177
|
+
} = ctx;
|
|
1178
|
+
|
|
1179
|
+
if (!adapter || typeof adapter.readState !== 'function') {
|
|
1180
|
+
throw new Error('gatherPullSignal requires a pr-state adapter with readState');
|
|
1181
|
+
}
|
|
1182
|
+
if (typeof runGh !== 'function') {
|
|
1183
|
+
throw new Error('gatherPullSignal requires an injected `runGh` runner');
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ONE gather + ONE verdict core: reads + verdict come from gatherPrSnapshot,
|
|
1187
|
+
// shared verbatim with the PR monitor so the verdict and monitor events can
|
|
1188
|
+
// never disagree. This function then does only the bounded --pull PROJECTION
|
|
1189
|
+
// (failure log excerpts, review-thread fix-list, summary, payload).
|
|
1190
|
+
const snap = await gatherPrSnapshot(ctx);
|
|
1191
|
+
const {
|
|
1192
|
+
state, requiredSet, threads, behind, conflicts, botStatusBlockers,
|
|
1193
|
+
requiredChecks, pendingChecks, draft, reviewDecision, verdict, evidence, degraded,
|
|
1194
|
+
} = snap;
|
|
1195
|
+
|
|
1196
|
+
// Legacy decision pass (READ-ONLY dryRun) for back-compat `state`. Shares the
|
|
1197
|
+
// snapshot's `degraded` so a pass-read failure is still surfaced in the payload.
|
|
1198
|
+
const pass = await safeRead('pass', () => runPass({ ...ctx, adapter, dryRun: true }), {
|
|
1199
|
+
degraded,
|
|
1200
|
+
fallback: { state: 'UNKNOWN', reason: 'Decision pass could not complete — a read failed; see verdict evidence.' },
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
const failures = gatherFailureExcerpts(runGh, state.checks, requiredSet, { maxFailures, maxExcerptLines, degraded });
|
|
1204
|
+
const reviewThreads = buildReviewThreads(threads, self, { maxThreads });
|
|
1205
|
+
|
|
1206
|
+
const blockers = computeBlockers({
|
|
1207
|
+
mergeable: state.mergeable,
|
|
1208
|
+
mergeStateStatus: state.mergeStateStatus,
|
|
1209
|
+
draft,
|
|
1210
|
+
reviewDecision,
|
|
1211
|
+
requiredClass: requiredChecks,
|
|
1212
|
+
botStatusBlockers,
|
|
1213
|
+
unresolvedThreadCount: reviewThreads.length,
|
|
1214
|
+
behind,
|
|
1215
|
+
conflicts,
|
|
1216
|
+
failuresCount: failures.length,
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
const summary = summarize({
|
|
1220
|
+
state: pass.state,
|
|
1221
|
+
failureCount: failures.length,
|
|
1222
|
+
threadCount: reviewThreads.length,
|
|
1223
|
+
blockers,
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
return buildPullPayload({
|
|
1227
|
+
pr,
|
|
1228
|
+
state: pass.state,
|
|
1229
|
+
verdict,
|
|
1230
|
+
evidence,
|
|
1231
|
+
degraded,
|
|
1232
|
+
reason: pass.reason,
|
|
1233
|
+
summary,
|
|
1234
|
+
mergeable: state.mergeable || 'UNKNOWN',
|
|
1235
|
+
mergeStateStatus: state.mergeStateStatus || 'UNKNOWN',
|
|
1236
|
+
draft,
|
|
1237
|
+
reviewDecision,
|
|
1238
|
+
blockers,
|
|
1239
|
+
requiredChecks,
|
|
1240
|
+
pendingChecks,
|
|
1241
|
+
behind,
|
|
1242
|
+
conflicts,
|
|
1243
|
+
failures,
|
|
1244
|
+
reviewThreads,
|
|
1245
|
+
maxFailures,
|
|
1246
|
+
maxThreads,
|
|
1247
|
+
maxExcerptLines,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
module.exports = {
|
|
1252
|
+
gatherPullSignal,
|
|
1253
|
+
gatherPrSnapshot,
|
|
1254
|
+
cleanLogLine,
|
|
1255
|
+
extractFailureExcerpt,
|
|
1256
|
+
jobIdFromUrl,
|
|
1257
|
+
dedupeFailures,
|
|
1258
|
+
buildReviewThreads,
|
|
1259
|
+
classifyRequiredChecks,
|
|
1260
|
+
pendingCheckNames,
|
|
1261
|
+
computeBlockers,
|
|
1262
|
+
renderPullSummary,
|
|
1263
|
+
buildPullPayload,
|
|
1264
|
+
computeVerdict,
|
|
1265
|
+
isSkipped,
|
|
1266
|
+
isPending,
|
|
1267
|
+
buildBotStatusBlockers,
|
|
1268
|
+
botFailureSummary,
|
|
1269
|
+
REVIEW_BOT_LOGINS,
|
|
1270
|
+
AUTOMATION_BOT_LOGINS,
|
|
1271
|
+
STATUS_BOT_LOGINS,
|
|
1272
|
+
DEFAULT_SETTLE_WINDOW_MS,
|
|
1273
|
+
};
|