@wjarka/cezarion 0.10.0-pr30.54
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 +21 -0
- package/README.md +838 -0
- package/dist/agent-config/account-identity.d.ts +44 -0
- package/dist/agent-config/account-identity.js +128 -0
- package/dist/agent-config/account-identity.js.map +1 -0
- package/dist/agent-config/catalog.d.ts +72 -0
- package/dist/agent-config/catalog.js +223 -0
- package/dist/agent-config/catalog.js.map +1 -0
- package/dist/agent-config/files.d.ts +46 -0
- package/dist/agent-config/files.js +119 -0
- package/dist/agent-config/files.js.map +1 -0
- package/dist/agent-config/model-settings/claude.d.ts +2 -0
- package/dist/agent-config/model-settings/claude.js +11 -0
- package/dist/agent-config/model-settings/claude.js.map +1 -0
- package/dist/agent-config/model-settings/codex.d.ts +2 -0
- package/dist/agent-config/model-settings/codex.js +16 -0
- package/dist/agent-config/model-settings/codex.js.map +1 -0
- package/dist/agent-config/model-settings/opencode.d.ts +2 -0
- package/dist/agent-config/model-settings/opencode.js +8 -0
- package/dist/agent-config/model-settings/opencode.js.map +1 -0
- package/dist/agent-config/model-settings/pi.d.ts +12 -0
- package/dist/agent-config/model-settings/pi.js +18 -0
- package/dist/agent-config/model-settings/pi.js.map +1 -0
- package/dist/agent-config/model-settings/shared.d.ts +9 -0
- package/dist/agent-config/model-settings/shared.js +93 -0
- package/dist/agent-config/model-settings/shared.js.map +1 -0
- package/dist/agent-config/model-settings/types.d.ts +9 -0
- package/dist/agent-config/model-settings/types.js +2 -0
- package/dist/agent-config/model-settings/types.js.map +1 -0
- package/dist/agent-config/models.d.ts +9 -0
- package/dist/agent-config/models.js +32 -0
- package/dist/agent-config/models.js.map +1 -0
- package/dist/agent-config/seed.d.ts +7 -0
- package/dist/agent-config/seed.js +90 -0
- package/dist/agent-config/seed.js.map +1 -0
- package/dist/agent-config/service.d.ts +44 -0
- package/dist/agent-config/service.js +71 -0
- package/dist/agent-config/service.js.map +1 -0
- package/dist/agent-config/validate.d.ts +21 -0
- package/dist/agent-config/validate.js +76 -0
- package/dist/agent-config/validate.js.map +1 -0
- package/dist/automations/coordinator.d.ts +26 -0
- package/dist/automations/coordinator.js +66 -0
- package/dist/automations/coordinator.js.map +1 -0
- package/dist/automations/github-poller.d.ts +74 -0
- package/dist/automations/github-poller.js +234 -0
- package/dist/automations/github-poller.js.map +1 -0
- package/dist/automations/scheduler.d.ts +42 -0
- package/dist/automations/scheduler.js +192 -0
- package/dist/automations/scheduler.js.map +1 -0
- package/dist/automations/store.d.ts +65 -0
- package/dist/automations/store.js +298 -0
- package/dist/automations/store.js.map +1 -0
- package/dist/automations/task-template.d.ts +18 -0
- package/dist/automations/task-template.js +95 -0
- package/dist/automations/task-template.js.map +1 -0
- package/dist/automations/types.d.ts +255 -0
- package/dist/automations/types.js +159 -0
- package/dist/automations/types.js.map +1 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +242 -0
- package/dist/config.js.map +1 -0
- package/dist/contract/agent-config.d.ts +153 -0
- package/dist/contract/agent-profiles.d.ts +347 -0
- package/dist/contract/automations.d.ts +935 -0
- package/dist/contract/events.d.ts +104 -0
- package/dist/contract/github.d.ts +556 -0
- package/dist/contract/health.d.ts +102 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.js +1895 -0
- package/dist/contract/projects.d.ts +210 -0
- package/dist/contract/repo.d.ts +234 -0
- package/dist/contract/runs.d.ts +1473 -0
- package/dist/contract/skills.d.ts +232 -0
- package/dist/contract/workflows.d.ts +218 -0
- package/dist/contract/workspace.d.ts +579 -0
- package/dist/core/agent-env.d.ts +31 -0
- package/dist/core/agent-env.js +371 -0
- package/dist/core/agent-env.js.map +1 -0
- package/dist/core/agent-model-policy.d.ts +10 -0
- package/dist/core/agent-model-policy.js +36 -0
- package/dist/core/agent-model-policy.js.map +1 -0
- package/dist/core/agent-profiles.d.ts +62 -0
- package/dist/core/agent-profiles.js +90 -0
- package/dist/core/agent-profiles.js.map +1 -0
- package/dist/core/agent-runner.d.ts +211 -0
- package/dist/core/agent-runner.js +68 -0
- package/dist/core/agent-runner.js.map +1 -0
- package/dist/core/ask.d.ts +105 -0
- package/dist/core/ask.js +187 -0
- package/dist/core/ask.js.map +1 -0
- package/dist/core/backend-detect.d.ts +16 -0
- package/dist/core/backend-detect.js +155 -0
- package/dist/core/backend-detect.js.map +1 -0
- package/dist/core/claude-cli-runner.d.ts +59 -0
- package/dist/core/claude-cli-runner.js +483 -0
- package/dist/core/claude-cli-runner.js.map +1 -0
- package/dist/core/claude-ui-mapper.d.ts +80 -0
- package/dist/core/claude-ui-mapper.js +598 -0
- package/dist/core/claude-ui-mapper.js.map +1 -0
- package/dist/core/codex-app-server-runner.d.ts +41 -0
- package/dist/core/codex-app-server-runner.js +584 -0
- package/dist/core/codex-app-server-runner.js.map +1 -0
- package/dist/core/codex-app-server-transport.d.ts +35 -0
- package/dist/core/codex-app-server-transport.js +150 -0
- package/dist/core/codex-app-server-transport.js.map +1 -0
- package/dist/core/codex-model-catalog.d.ts +10 -0
- package/dist/core/codex-model-catalog.js +110 -0
- package/dist/core/codex-model-catalog.js.map +1 -0
- package/dist/core/codex-ui-mapper.d.ts +112 -0
- package/dist/core/codex-ui-mapper.js +766 -0
- package/dist/core/codex-ui-mapper.js.map +1 -0
- package/dist/core/model-identity.d.ts +125 -0
- package/dist/core/model-identity.js +132 -0
- package/dist/core/model-identity.js.map +1 -0
- package/dist/core/model-presets.d.ts +21 -0
- package/dist/core/model-presets.js +81 -0
- package/dist/core/model-presets.js.map +1 -0
- package/dist/core/ndjson.d.ts +6 -0
- package/dist/core/ndjson.js +23 -0
- package/dist/core/ndjson.js.map +1 -0
- package/dist/core/opencode-model-catalog.d.ts +32 -0
- package/dist/core/opencode-model-catalog.js +153 -0
- package/dist/core/opencode-model-catalog.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +32 -0
- package/dist/core/opencode-server-runner.js +882 -0
- package/dist/core/opencode-server-runner.js.map +1 -0
- package/dist/core/opencode-ui-mapper.d.ts +137 -0
- package/dist/core/opencode-ui-mapper.js +800 -0
- package/dist/core/opencode-ui-mapper.js.map +1 -0
- package/dist/core/pi-model-catalog.d.ts +31 -0
- package/dist/core/pi-model-catalog.js +152 -0
- package/dist/core/pi-model-catalog.js.map +1 -0
- package/dist/core/pi-runner.d.ts +24 -0
- package/dist/core/pi-runner.js +403 -0
- package/dist/core/pi-runner.js.map +1 -0
- package/dist/core/pi-ui-mapper.d.ts +36 -0
- package/dist/core/pi-ui-mapper.js +304 -0
- package/dist/core/pi-ui-mapper.js.map +1 -0
- package/dist/core/process-usage.d.ts +66 -0
- package/dist/core/process-usage.js +193 -0
- package/dist/core/process-usage.js.map +1 -0
- package/dist/core/provider-auth.d.ts +154 -0
- package/dist/core/provider-auth.js +509 -0
- package/dist/core/provider-auth.js.map +1 -0
- package/dist/core/provider-availability.d.ts +3 -0
- package/dist/core/provider-availability.js +14 -0
- package/dist/core/provider-availability.js.map +1 -0
- package/dist/core/runner-factory.d.ts +8 -0
- package/dist/core/runner-factory.js +25 -0
- package/dist/core/runner-factory.js.map +1 -0
- package/dist/core/runner-model-catalog.d.ts +29 -0
- package/dist/core/runner-model-catalog.js +78 -0
- package/dist/core/runner-model-catalog.js.map +1 -0
- package/dist/core/secret-redaction.d.ts +37 -0
- package/dist/core/secret-redaction.js +115 -0
- package/dist/core/secret-redaction.js.map +1 -0
- package/dist/core/shell-env.d.ts +28 -0
- package/dist/core/shell-env.js +56 -0
- package/dist/core/shell-env.js.map +1 -0
- package/dist/core/tool-display.d.ts +31 -0
- package/dist/core/tool-display.js +179 -0
- package/dist/core/tool-display.js.map +1 -0
- package/dist/core/ui-events.d.ts +335 -0
- package/dist/core/ui-events.js +25 -0
- package/dist/core/ui-events.js.map +1 -0
- package/dist/core/usage-limit.d.ts +42 -0
- package/dist/core/usage-limit.js +209 -0
- package/dist/core/usage-limit.js.map +1 -0
- package/dist/core/usage.d.ts +11 -0
- package/dist/core/usage.js +15 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/core/v1-text-coalescer.d.ts +36 -0
- package/dist/core/v1-text-coalescer.js +72 -0
- package/dist/core/v1-text-coalescer.js.map +1 -0
- package/dist/git-diff-base.d.ts +85 -0
- package/dist/git-diff-base.js +180 -0
- package/dist/git-diff-base.js.map +1 -0
- package/dist/git-refs.d.ts +11 -0
- package/dist/git-refs.js +14 -0
- package/dist/git-refs.js.map +1 -0
- package/dist/git-worktree.d.ts +161 -0
- package/dist/git-worktree.js +508 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +62 -0
- package/dist/handoff.js +154 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +680 -0
- package/dist/index.js.map +1 -0
- package/dist/install-as-command.d.ts +40 -0
- package/dist/install-as-command.js +52 -0
- package/dist/install-as-command.js.map +1 -0
- package/dist/pack-check.d.ts +17 -0
- package/dist/pack-check.js +27 -0
- package/dist/pack-check.js.map +1 -0
- package/dist/paths.d.ts +140 -0
- package/dist/paths.js +192 -0
- package/dist/paths.js.map +1 -0
- package/dist/planner.d.ts +27 -0
- package/dist/planner.js +268 -0
- package/dist/planner.js.map +1 -0
- package/dist/release/manifests.d.ts +81 -0
- package/dist/release/manifests.js +74 -0
- package/dist/release/manifests.js.map +1 -0
- package/dist/release/snapshot.d.ts +67 -0
- package/dist/release/snapshot.js +97 -0
- package/dist/release/snapshot.js.map +1 -0
- package/dist/release/stable.d.ts +38 -0
- package/dist/release/stable.js +55 -0
- package/dist/release/stable.js.map +1 -0
- package/dist/runs/agent-tmpdir.d.ts +41 -0
- package/dist/runs/agent-tmpdir.js +183 -0
- package/dist/runs/agent-tmpdir.js.map +1 -0
- package/dist/runs/auto-name.d.ts +75 -0
- package/dist/runs/auto-name.js +179 -0
- package/dist/runs/auto-name.js.map +1 -0
- package/dist/runs/event-history.d.ts +50 -0
- package/dist/runs/event-history.js +613 -0
- package/dist/runs/event-history.js.map +1 -0
- package/dist/runs/retention.d.ts +60 -0
- package/dist/runs/retention.js +86 -0
- package/dist/runs/retention.js.map +1 -0
- package/dist/runs/review-gate.d.ts +18 -0
- package/dist/runs/review-gate.js +21 -0
- package/dist/runs/review-gate.js.map +1 -0
- package/dist/runs/run-index.d.ts +19 -0
- package/dist/runs/run-index.js +43 -0
- package/dist/runs/run-index.js.map +1 -0
- package/dist/runs/store.d.ts +446 -0
- package/dist/runs/store.js +1118 -0
- package/dist/runs/store.js.map +1 -0
- package/dist/runs/task-markers.d.ts +28 -0
- package/dist/runs/task-markers.js +74 -0
- package/dist/runs/task-markers.js.map +1 -0
- package/dist/runs/task-refs.d.ts +25 -0
- package/dist/runs/task-refs.js +74 -0
- package/dist/runs/task-refs.js.map +1 -0
- package/dist/runs/ui-event-sink.d.ts +81 -0
- package/dist/runs/ui-event-sink.js +210 -0
- package/dist/runs/ui-event-sink.js.map +1 -0
- package/dist/server/app-type.d.ts +21 -0
- package/dist/server/app-type.js +2 -0
- package/dist/server/app-type.js.map +1 -0
- package/dist/server/capabilities.d.ts +70 -0
- package/dist/server/capabilities.js +152 -0
- package/dist/server/capabilities.js.map +1 -0
- package/dist/server/checkout.d.ts +128 -0
- package/dist/server/checkout.js +296 -0
- package/dist/server/checkout.js.map +1 -0
- package/dist/server/forge/github.d.ts +513 -0
- package/dist/server/forge/github.js +2364 -0
- package/dist/server/forge/github.js.map +1 -0
- package/dist/server/forge/index.d.ts +40 -0
- package/dist/server/forge/index.js +72 -0
- package/dist/server/forge/index.js.map +1 -0
- package/dist/server/forge/types.d.ts +242 -0
- package/dist/server/forge/types.js +2 -0
- package/dist/server/forge/types.js.map +1 -0
- package/dist/server/fs-browse.d.ts +105 -0
- package/dist/server/fs-browse.js +182 -0
- package/dist/server/fs-browse.js.map +1 -0
- package/dist/server/git-changes.d.ts +213 -0
- package/dist/server/git-changes.js +555 -0
- package/dist/server/git-changes.js.map +1 -0
- package/dist/server/git.d.ts +28 -0
- package/dist/server/git.js +112 -0
- package/dist/server/git.js.map +1 -0
- package/dist/server/github.d.ts +9 -0
- package/dist/server/github.js +8 -0
- package/dist/server/github.js.map +1 -0
- package/dist/server/launch-key.d.ts +7 -0
- package/dist/server/launch-key.js +33 -0
- package/dist/server/launch-key.js.map +1 -0
- package/dist/server/open-in-app.d.ts +66 -0
- package/dist/server/open-in-app.js +211 -0
- package/dist/server/open-in-app.js.map +1 -0
- package/dist/server/open-in-terminal.d.ts +70 -0
- package/dist/server/open-in-terminal.js +179 -0
- package/dist/server/open-in-terminal.js.map +1 -0
- package/dist/server/pr.d.ts +7 -0
- package/dist/server/pr.js +7 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/project-context.d.ts +120 -0
- package/dist/server/project-context.js +182 -0
- package/dist/server/project-context.js.map +1 -0
- package/dist/server/provider-action-gate.d.ts +8 -0
- package/dist/server/provider-action-gate.js +56 -0
- package/dist/server/provider-action-gate.js.map +1 -0
- package/dist/server/provider-auth-runtime.d.ts +21 -0
- package/dist/server/provider-auth-runtime.js +66 -0
- package/dist/server/provider-auth-runtime.js.map +1 -0
- package/dist/server/server.d.ts +13138 -0
- package/dist/server/server.js +5263 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/static-ui.d.ts +49 -0
- package/dist/server/static-ui.js +92 -0
- package/dist/server/static-ui.js.map +1 -0
- package/dist/server/validators.d.ts +97 -0
- package/dist/server/validators.js +86 -0
- package/dist/server/validators.js.map +1 -0
- package/dist/server/ws.d.ts +85 -0
- package/dist/server/ws.js +218 -0
- package/dist/server/ws.js.map +1 -0
- package/dist/server/wsl.d.ts +25 -0
- package/dist/server/wsl.js +92 -0
- package/dist/server/wsl.js.map +1 -0
- package/dist/server-install/engine.d.ts +50 -0
- package/dist/server-install/engine.js +324 -0
- package/dist/server-install/engine.js.map +1 -0
- package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
- package/dist/server-install/platforms/macosx-ngrok.js +345 -0
- package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
- package/dist/server-install/platforms/ubuntu-vps.d.ts +78 -0
- package/dist/server-install/platforms/ubuntu-vps.js +1016 -0
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
- package/dist/server-install/state.d.ts +52 -0
- package/dist/server-install/state.js +182 -0
- package/dist/server-install/state.js.map +1 -0
- package/dist/server-install/steps.d.ts +109 -0
- package/dist/server-install/steps.js +334 -0
- package/dist/server-install/steps.js.map +1 -0
- package/dist/server-install/strategies.d.ts +4 -0
- package/dist/server-install/strategies.js +20 -0
- package/dist/server-install/strategies.js.map +1 -0
- package/dist/server-install/types.d.ts +287 -0
- package/dist/server-install/types.js +121 -0
- package/dist/server-install/types.js.map +1 -0
- package/dist/server-install/ui.d.ts +42 -0
- package/dist/server-install/ui.js +130 -0
- package/dist/server-install/ui.js.map +1 -0
- package/dist/skills-banner.d.ts +22 -0
- package/dist/skills-banner.js +41 -0
- package/dist/skills-banner.js.map +1 -0
- package/dist/skills-remote.d.ts +84 -0
- package/dist/skills-remote.js +468 -0
- package/dist/skills-remote.js.map +1 -0
- package/dist/skills-update.d.ts +88 -0
- package/dist/skills-update.js +392 -0
- package/dist/skills-update.js.map +1 -0
- package/dist/skills.d.ts +82 -0
- package/dist/skills.js +251 -0
- package/dist/skills.js.map +1 -0
- package/dist/todos.d.ts +48 -0
- package/dist/todos.js +201 -0
- package/dist/todos.js.map +1 -0
- package/dist/ui-state.d.ts +26 -0
- package/dist/ui-state.js +38 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/update-check.d.ts +12 -0
- package/dist/update-check.js +41 -0
- package/dist/update-check.js.map +1 -0
- package/dist/workflows/load.d.ts +15 -0
- package/dist/workflows/load.js +56 -0
- package/dist/workflows/load.js.map +1 -0
- package/dist/workflows/run.d.ts +758 -0
- package/dist/workflows/run.js +3389 -0
- package/dist/workflows/run.js.map +1 -0
- package/dist/workflows/types.d.ts +153 -0
- package/dist/workflows/types.js +212 -0
- package/dist/workflows/types.js.map +1 -0
- package/dist/workspace/agent-accounts.d.ts +153 -0
- package/dist/workspace/agent-accounts.js +304 -0
- package/dist/workspace/agent-accounts.js.map +1 -0
- package/dist/workspace/agent-profiles.d.ts +78 -0
- package/dist/workspace/agent-profiles.js +115 -0
- package/dist/workspace/agent-profiles.js.map +1 -0
- package/dist/workspace/config.d.ts +169 -0
- package/dist/workspace/config.js +379 -0
- package/dist/workspace/config.js.map +1 -0
- package/dist/workspace/migrations.d.ts +45 -0
- package/dist/workspace/migrations.js +147 -0
- package/dist/workspace/migrations.js.map +1 -0
- package/dist/workspace/projects-cli.d.ts +25 -0
- package/dist/workspace/projects-cli.js +172 -0
- package/dist/workspace/projects-cli.js.map +1 -0
- package/dist/workspace/projects.d.ts +109 -0
- package/dist/workspace/projects.js +250 -0
- package/dist/workspace/projects.js.map +1 -0
- package/dist/workspace/semaphore.d.ts +179 -0
- package/dist/workspace/semaphore.js +195 -0
- package/dist/workspace/semaphore.js.map +1 -0
- package/dist/workspace/ui-state.d.ts +34 -0
- package/dist/workspace/ui-state.js +53 -0
- package/dist/workspace/ui-state.js.map +1 -0
- package/package.json +70 -0
- package/scripts/check-pack.mjs +46 -0
- package/scripts/inline-contract.mjs +112 -0
- package/scripts/install-as-command.mjs +120 -0
- package/scripts/mock-claude.mjs +520 -0
- package/scripts/mock-pi-rpc.mjs +83 -0
- package/scripts/sync-readme.mjs +20 -0
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/alert-dialog-B4Ofsgv7.js +1 -0
- package/web/dist/assets/arrow-down-Dy8dup7j.js +1 -0
- package/web/dist/assets/arrow-left-BJaucbhn.js +1 -0
- package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
- package/web/dist/assets/centered-state-duPW0phQ.js +43 -0
- package/web/dist/assets/chevron-right-Be4A5TCS.js +1 -0
- package/web/dist/assets/chunk-BO2N2NFS-DE6qKn3r.js +131 -0
- package/web/dist/assets/collapsible-DdcNBdSk.js +1 -0
- package/web/dist/assets/commit-list-CgvN9vec.js +1 -0
- package/web/dist/assets/compare-variants-CQA2nd65.js +1 -0
- package/web/dist/assets/core-BCsw8oQw.js +12 -0
- package/web/dist/assets/css-BsVw1vtW.js +1 -0
- package/web/dist/assets/diff-B6Agp5Hl.js +3 -0
- package/web/dist/assets/diff-stat-BoXuGkgq.js +1 -0
- package/web/dist/assets/diff-view-CD8XI-fj.js +6 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/dropdown-menu-B3SYw-k5.js +1 -0
- package/web/dist/assets/editable-title-B-hjp5wl.js +1 -0
- package/web/dist/assets/ellipsis-vertical-BYwzw0xl.js +1 -0
- package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
- package/web/dist/assets/file-BRj5eISx.js +1 -0
- package/web/dist/assets/folder-z4oT5981.js +1 -0
- package/web/dist/assets/git-toolbar-owSEkYld.js +1 -0
- package/web/dist/assets/github-Bz2VgRX3.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
- package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
- package/web/dist/assets/html-BY76lXLH.js +1 -0
- package/web/dist/assets/image-preview-h3WFluuy.js +1 -0
- package/web/dist/assets/index-BbcQNT92.css +2 -0
- package/web/dist/assets/index-EtQQQ6Kj.js +7 -0
- package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/json-qhed-kSA.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
- package/web/dist/assets/lib-BQXq3kEf.js +1 -0
- package/web/dist/assets/lib-BxQXEXDF.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/markdown-DqBKYPiB.js +2 -0
- package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
- package/web/dist/assets/new-task-form-BdJKRBJX.js +1 -0
- package/web/dist/assets/pill-BQfmfpYp.js +1 -0
- package/web/dist/assets/project-router-DxgFZY1l.js +1 -0
- package/web/dist/assets/prompt-templates-B6m16gq-.js +15 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
- package/web/dist/assets/refresh-cw-mhSpxf2a.js +1 -0
- package/web/dist/assets/repo-git-CUNRxR3_.js +1 -0
- package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
- package/web/dist/assets/run-diff-DsmmDVfx.js +3 -0
- package/web/dist/assets/run-header-BkhrseLJ.js +1 -0
- package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
- package/web/dist/assets/search-x-BqDMB2g8.js +1 -0
- package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
- package/web/dist/assets/skill-empty-hint-BWEvTggc.js +1 -0
- package/web/dist/assets/skills-9oR_oHse.js +1 -0
- package/web/dist/assets/skills-DwIxnnWO.js +1 -0
- package/web/dist/assets/sparkles-DIoTQpyx.js +1 -0
- package/web/dist/assets/sql-BsFa4tDR.js +1 -0
- package/web/dist/assets/square-terminal-Cc7CuQxc.js +1 -0
- package/web/dist/assets/tab-link-D1fQgpmO.js +1 -0
- package/web/dist/assets/task-changes-BKeLlDpK.js +1 -0
- package/web/dist/assets/task-commits-gcEFZIa5.js +1 -0
- package/web/dist/assets/task-files-BGAqoXTW.js +2 -0
- package/web/dist/assets/task-thread-DT8aUYTS.js +9 -0
- package/web/dist/assets/textarea-BOVbfy5C.js +1 -0
- package/web/dist/assets/thread-loading-CypW73KD.js +1 -0
- package/web/dist/assets/toml-CcmNWLt0.js +1 -0
- package/web/dist/assets/trash-2-CJq6LHWn.js +1 -0
- package/web/dist/assets/triangle-alert-CjDFvU27.js +1 -0
- package/web/dist/assets/tsx-udAQXfEw.js +1 -0
- package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
- package/web/dist/assets/upload-CPDZlJeK.js +1 -0
- package/web/dist/assets/use-desktop-5JsLmi0a.js +1 -0
- package/web/dist/assets/use-submit-shortcut-CHVFevty.js +1 -0
- package/web/dist/assets/utils-DiO6zyJd.js +64 -0
- package/web/dist/assets/workflows-rQDFzWRt.js +11 -0
- package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
- package/web/dist/assets/zoomable-image-COWqb11v.js +1 -0
- package/web/dist/index.html +80 -0
- package/web/dist/open-mercato.svg +11 -0
|
@@ -0,0 +1,2364 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { REFERENCE_STATUS_MAX } from '../../contract/index.js';
|
|
5
|
+
import { autosaveCommit } from '../../git-worktree.js';
|
|
6
|
+
/**
|
|
7
|
+
* The GitHub forge driver — all `gh`-CLI logic in one place, moved here from
|
|
8
|
+
* `src/server/github.ts` (tab listing) and `src/server/pr.ts` (draft PRs)
|
|
9
|
+
* behind the `ForgeDriver` seam. Those modules remain as thin delegates.
|
|
10
|
+
* `/api/github`'s response shape is this driver's serialization and is
|
|
11
|
+
* protected by BACKWARD_COMPATIBILITY.md — additive changes only.
|
|
12
|
+
*/
|
|
13
|
+
const exec = promisify(execFile);
|
|
14
|
+
export const GH_PR_DIFF_FILE_CAP = 300;
|
|
15
|
+
export const GH_PR_PATCH_CAP = 512 * 1024;
|
|
16
|
+
export const GH_PR_DIFF_JSON_CAP = 4 * 1024 * 1024;
|
|
17
|
+
const ghPrFileSchema = z.object({
|
|
18
|
+
filename: z.string().min(1),
|
|
19
|
+
previous_filename: z.string().optional(),
|
|
20
|
+
status: z.enum(['added', 'modified', 'removed', 'renamed', 'copied', 'changed']),
|
|
21
|
+
additions: z.number().int().nonnegative(),
|
|
22
|
+
deletions: z.number().int().nonnegative(),
|
|
23
|
+
patch: z.string().optional(),
|
|
24
|
+
});
|
|
25
|
+
const ghPrHeadSchema = z.object({ headRefOid: z.string().regex(/^[0-9a-f]{40}$/i) });
|
|
26
|
+
export class GithubPrNotFoundError extends Error {
|
|
27
|
+
}
|
|
28
|
+
const prDiffCache = new Map();
|
|
29
|
+
/** Fetch no more than the three GitHub pages represented by the public 300-file response cap. */
|
|
30
|
+
export async function fetchPrFilePages(runPage) {
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (let page = 1; page <= GH_PR_DIFF_FILE_CAP / 100; page++) {
|
|
33
|
+
const next = z.array(z.unknown()).parse(JSON.parse(await runPage(page)));
|
|
34
|
+
rows.push(...next);
|
|
35
|
+
if (next.length < 100)
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
export async function fetchGithubPrDiff(repoRoot, number, refresh = false) {
|
|
41
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
42
|
+
return mockGithubPrDiff(number);
|
|
43
|
+
try {
|
|
44
|
+
const head = ghPrHeadSchema.parse(JSON.parse(await gh(repoRoot, ['pr', 'view', String(number), '--json', 'headRefOid']))).headRefOid;
|
|
45
|
+
const key = `${repoRoot}\0${number}\0${head}`;
|
|
46
|
+
const hit = prDiffCache.get(key);
|
|
47
|
+
if (!refresh && hit && Date.now() - hit.at < CACHE_MS)
|
|
48
|
+
return hit.data;
|
|
49
|
+
const rows = z.array(ghPrFileSchema).parse(await fetchPrFilePages((page) => gh(repoRoot, ['api', `repos/{owner}/{repo}/pulls/${number}/files?per_page=100&page=${page}`], 30_000)));
|
|
50
|
+
const limited = rows.slice(0, GH_PR_DIFF_FILE_CAP);
|
|
51
|
+
// A full third page may have a successor. Without fetching a 301st file, conservatively call
|
|
52
|
+
// the response partial rather than claiming completeness we cannot prove.
|
|
53
|
+
let responseTruncated = rows.length >= GH_PR_DIFF_FILE_CAP;
|
|
54
|
+
const reasons = responseTruncated ? [`Only the first ${GH_PR_DIFF_FILE_CAP} files are shown.`] : [];
|
|
55
|
+
const files = limited.map((row) => {
|
|
56
|
+
let patch = row.patch;
|
|
57
|
+
let truncated = false;
|
|
58
|
+
let patchUnavailableReason;
|
|
59
|
+
if (patch !== undefined && Buffer.byteLength(patch, 'utf8') > GH_PR_PATCH_CAP) {
|
|
60
|
+
patch = undefined;
|
|
61
|
+
truncated = true;
|
|
62
|
+
patchUnavailableReason = 'too-large';
|
|
63
|
+
responseTruncated = true;
|
|
64
|
+
}
|
|
65
|
+
else if (patch === undefined) {
|
|
66
|
+
patchUnavailableReason = row.additions === 0 && row.deletions === 0 ? 'binary' : 'not-provided';
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
path: row.filename,
|
|
70
|
+
...(row.previous_filename ? { previousPath: row.previous_filename } : {}),
|
|
71
|
+
status: row.status,
|
|
72
|
+
additions: row.additions,
|
|
73
|
+
deletions: row.deletions,
|
|
74
|
+
...(patch !== undefined ? { patch } : {}),
|
|
75
|
+
...(patchUnavailableReason ? { patchUnavailableReason } : {}),
|
|
76
|
+
...(truncated ? { truncated: true } : {}),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
let kept = files;
|
|
80
|
+
while (kept.length > 0 &&
|
|
81
|
+
Buffer.byteLength(JSON.stringify({ available: true, number, headSha: head, files: kept }), 'utf8') >
|
|
82
|
+
GH_PR_DIFF_JSON_CAP) {
|
|
83
|
+
kept = kept.slice(0, -1);
|
|
84
|
+
responseTruncated = true;
|
|
85
|
+
}
|
|
86
|
+
if (kept.length < files.length)
|
|
87
|
+
reasons.push('The response size limit omitted some files.');
|
|
88
|
+
if (files.some((file) => file.truncated))
|
|
89
|
+
reasons.push('One or more patches exceeded the per-file limit.');
|
|
90
|
+
const data = {
|
|
91
|
+
available: true,
|
|
92
|
+
number,
|
|
93
|
+
headSha: head,
|
|
94
|
+
files: kept,
|
|
95
|
+
additions: rows.reduce((sum, row) => sum + row.additions, 0),
|
|
96
|
+
deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
|
|
97
|
+
truncated: responseTruncated,
|
|
98
|
+
...(reasons.length ? { reason: reasons.join(' ') } : {}),
|
|
99
|
+
};
|
|
100
|
+
prDiffCache.set(key, { at: Date.now(), data });
|
|
101
|
+
while (prDiffCache.size > 50)
|
|
102
|
+
prDiffCache.delete(prDiffCache.keys().next().value);
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
+
if (/HTTP 404|Could not resolve to a PullRequest|no pull requests found/i.test(message)) {
|
|
108
|
+
throw new GithubPrNotFoundError(`Pull request #${number} was not found`);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
available: false,
|
|
112
|
+
reason: /ENOENT/.test(message)
|
|
113
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
114
|
+
: firstLine(message),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function mockGithubPrDiff(number) {
|
|
119
|
+
return {
|
|
120
|
+
available: true,
|
|
121
|
+
number,
|
|
122
|
+
headSha: '0123456789abcdef0123456789abcdef01234567',
|
|
123
|
+
additions: 15,
|
|
124
|
+
deletions: 4,
|
|
125
|
+
truncated: true,
|
|
126
|
+
reason: 'One or more patches were not provided by GitHub.',
|
|
127
|
+
files: [
|
|
128
|
+
{ path: 'src/session.ts', status: 'modified', additions: 8, deletions: 3, patch: '@@ -1,3 +1,4 @@\n-old\n+new\n context' },
|
|
129
|
+
{ path: 'src/new-name.ts', previousPath: 'src/old-name.ts', status: 'renamed', additions: 7, deletions: 1, patch: '@@ -1 +1 @@\n-old name\n+new name' },
|
|
130
|
+
{ path: 'assets/logo.png', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'binary' },
|
|
131
|
+
{ path: 'generated/output.txt', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'too-large', truncated: true },
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// `gh … --json` output — validated at the boundary, extras stripped.
|
|
136
|
+
const ghAuthor = z.object({ login: z.string() }).nullish();
|
|
137
|
+
// `color` is the 6-hex GitHub label color (no `#`), '' when gh omits it.
|
|
138
|
+
const ghLabel = z.object({ name: z.string(), color: z.string().default('') });
|
|
139
|
+
const ghIssueSchema = z.object({
|
|
140
|
+
number: z.number(),
|
|
141
|
+
title: z.string(),
|
|
142
|
+
author: ghAuthor,
|
|
143
|
+
createdAt: z.string(),
|
|
144
|
+
labels: z.array(ghLabel).default([]),
|
|
145
|
+
body: z.string().nullish(),
|
|
146
|
+
url: z.string(),
|
|
147
|
+
});
|
|
148
|
+
// One check run's `gh --json statusCheckRollup` entry — every field optional/nullish because
|
|
149
|
+
// gh's shape varies by check provider (exported so #400's unit tests can build fixtures).
|
|
150
|
+
export const ghCheckRunSchema = z.object({
|
|
151
|
+
state: z.string().nullish(),
|
|
152
|
+
status: z.string().nullish(),
|
|
153
|
+
conclusion: z.string().nullish(),
|
|
154
|
+
});
|
|
155
|
+
const ghStatusCheckRollup = z.array(ghCheckRunSchema).nullish();
|
|
156
|
+
// The list tier no longer requests `statusCheckRollup` (#664) — it is hydrated lazily per
|
|
157
|
+
// on-screen PR row (`fetchGithubChecks`). `checks` is set to `null` on list rows; the schema
|
|
158
|
+
// keeps no rollup field because the list call never asks for it.
|
|
159
|
+
const ghPrSchema = ghIssueSchema.extend({
|
|
160
|
+
isDraft: z.boolean().default(false),
|
|
161
|
+
additions: z.number().default(0),
|
|
162
|
+
deletions: z.number().default(0),
|
|
163
|
+
});
|
|
164
|
+
const ghPrViewSchema = z.object({
|
|
165
|
+
number: z.number(),
|
|
166
|
+
url: z.string(),
|
|
167
|
+
state: z.string().default('OPEN'),
|
|
168
|
+
isDraft: z.boolean().default(false),
|
|
169
|
+
statusCheckRollup: ghStatusCheckRollup,
|
|
170
|
+
});
|
|
171
|
+
const mergeCheckSchema = z.object({
|
|
172
|
+
name: z.string().default('Check'),
|
|
173
|
+
state: z.string().nullish(),
|
|
174
|
+
status: z.string().nullish(),
|
|
175
|
+
conclusion: z.string().nullish(),
|
|
176
|
+
detailsUrl: z.string().nullish(),
|
|
177
|
+
});
|
|
178
|
+
const mergePrSchema = z.object({
|
|
179
|
+
number: z.number(),
|
|
180
|
+
title: z.string(),
|
|
181
|
+
url: z.string(),
|
|
182
|
+
state: z.string(),
|
|
183
|
+
isDraft: z.boolean().default(false),
|
|
184
|
+
headRefName: z.string(),
|
|
185
|
+
baseRefName: z.string(),
|
|
186
|
+
headRefOid: z.string().regex(/^[0-9a-f]{40}$/),
|
|
187
|
+
mergeable: z.string().nullish(),
|
|
188
|
+
mergeStateStatus: z.string().nullish(),
|
|
189
|
+
reviewDecision: z.string().nullish(),
|
|
190
|
+
statusCheckRollup: z.array(mergeCheckSchema).nullish(),
|
|
191
|
+
});
|
|
192
|
+
const repoMergePolicySchema = z.object({
|
|
193
|
+
allow_merge_commit: z.boolean().default(false),
|
|
194
|
+
allow_squash_merge: z.boolean().default(false),
|
|
195
|
+
allow_rebase_merge: z.boolean().default(false),
|
|
196
|
+
merge_commit_title: z.string().nullish(),
|
|
197
|
+
squash_merge_commit_title: z.string().nullish(),
|
|
198
|
+
});
|
|
199
|
+
const ghMergeResultSchema = z.object({
|
|
200
|
+
merged: z.boolean(),
|
|
201
|
+
message: z.string().nullish(),
|
|
202
|
+
sha: z.string().nullish(),
|
|
203
|
+
});
|
|
204
|
+
/** Exported for unit tests (#400) — collapses a zod-validated `statusCheckRollup` array down to
|
|
205
|
+
* the single enum the GitHub tab (list rows + detail badge) renders. */
|
|
206
|
+
export function rollupToChecks(rollup) {
|
|
207
|
+
if (!rollup || rollup.length === 0)
|
|
208
|
+
return null;
|
|
209
|
+
const states = rollup.map((r) => (r.conclusion || r.state || r.status || '').toUpperCase());
|
|
210
|
+
if (states.some((s) => ['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED'].includes(s)))
|
|
211
|
+
return 'failing';
|
|
212
|
+
if (states.some((s) => ['PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED', ''].includes(s)))
|
|
213
|
+
return 'pending';
|
|
214
|
+
return 'passing';
|
|
215
|
+
}
|
|
216
|
+
async function gh(repoRoot, args, timeout = 15_000) {
|
|
217
|
+
const { stdout } = await exec('gh', args, {
|
|
218
|
+
cwd: repoRoot,
|
|
219
|
+
timeout,
|
|
220
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
221
|
+
});
|
|
222
|
+
return stdout;
|
|
223
|
+
}
|
|
224
|
+
/** GraphQL's max page size; pagination is capped at 10 pages/kind (1000 rows — the GUI's full
|
|
225
|
+
* background shot). Rows past the window keep `comments: 0`, which the UI reads as "no badge". */
|
|
226
|
+
export const GH_COUNTS_MAX_PAGES = 10;
|
|
227
|
+
const countsQuery = (root) => `
|
|
228
|
+
query ($owner: String!, $name: String!, $endCursor: String) {
|
|
229
|
+
repository(owner: $owner, name: $name) {
|
|
230
|
+
${root}(first: 100, after: $endCursor, states: OPEN,
|
|
231
|
+
orderBy: {field: CREATED_AT, direction: DESC}) {
|
|
232
|
+
nodes { number comments { totalCount } }
|
|
233
|
+
pageInfo { hasNextPage endCursor }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}`;
|
|
237
|
+
const ghCountsPageSchema = z.object({
|
|
238
|
+
nodes: z.array(z.object({ number: z.number(), comments: z.object({ totalCount: z.number() }) })),
|
|
239
|
+
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullish() }),
|
|
240
|
+
});
|
|
241
|
+
/** Validate + flatten one gh GraphQL counts response into a `number → count` map plus the page
|
|
242
|
+
* cursor. Exported for unit tests (the zod boundary + shape). Throws on a malformed envelope. */
|
|
243
|
+
export function parseCountsPage(out, root) {
|
|
244
|
+
const parsed = JSON.parse(out);
|
|
245
|
+
const page = ghCountsPageSchema.parse(parsed?.data?.repository?.[root]);
|
|
246
|
+
const counts = {};
|
|
247
|
+
for (const node of page.nodes)
|
|
248
|
+
counts[node.number] = node.comments.totalCount;
|
|
249
|
+
return { counts, hasNextPage: page.pageInfo.hasNextPage, endCursor: page.pageInfo.endCursor ?? null };
|
|
250
|
+
}
|
|
251
|
+
async function paginateCounts(runGraphql, owner, name, root, maxPages) {
|
|
252
|
+
const counts = {};
|
|
253
|
+
let cursor = null;
|
|
254
|
+
for (let page = 0; page < maxPages; page++) {
|
|
255
|
+
const variables = { owner, name };
|
|
256
|
+
if (cursor)
|
|
257
|
+
variables.endCursor = cursor;
|
|
258
|
+
const res = parseCountsPage(await runGraphql(countsQuery(root), variables), root);
|
|
259
|
+
Object.assign(counts, res.counts);
|
|
260
|
+
if (!res.hasNextPage || !res.endCursor)
|
|
261
|
+
break;
|
|
262
|
+
cursor = res.endCursor;
|
|
263
|
+
}
|
|
264
|
+
return counts;
|
|
265
|
+
}
|
|
266
|
+
/** Comment counts for open issues and PRs as `number → count` maps. Two independent paginated
|
|
267
|
+
* queries (issues and PRs need separate cursors) run in parallel; any failure degrades the whole
|
|
268
|
+
* thing to empty maps so the tab is never held up by counts. Exported for unit tests. */
|
|
269
|
+
export async function fetchCommentCounts(runGraphql, owner, name, maxPages = GH_COUNTS_MAX_PAGES) {
|
|
270
|
+
try {
|
|
271
|
+
const [issues, prs] = await Promise.all([
|
|
272
|
+
paginateCounts(runGraphql, owner, name, 'issues', maxPages),
|
|
273
|
+
paginateCounts(runGraphql, owner, name, 'pullRequests', maxPages),
|
|
274
|
+
]);
|
|
275
|
+
return { issues, prs };
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return { issues: {}, prs: {} };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** `owner/name` → `{ owner, name }`, or null when the handle isn't a clean two-part slug. */
|
|
282
|
+
export function parseOwnerName(nameWithOwner) {
|
|
283
|
+
const [owner, name, ...rest] = nameWithOwner.trim().split('/');
|
|
284
|
+
return owner && name && rest.length === 0 ? { owner, name } : null;
|
|
285
|
+
}
|
|
286
|
+
/* Reads degrade to `available: false` with a hint — never an error (plan rule
|
|
287
|
+
7): no `gh`, no remote, offline all land on the same quiet path. A short
|
|
288
|
+
cache keeps tab switches from hammering the GitHub API; a cached fetch with
|
|
289
|
+
a bigger limit than asked serves fine (it's a superset). Keyed by `repoRoot`
|
|
290
|
+
(multi-project workspace, step 2.6): one project's — possibly private —
|
|
291
|
+
issues/PRs must never be served under another project's scope. Bounded like
|
|
292
|
+
`commentsCache` so an unbounded workspace can't grow it without limit. */
|
|
293
|
+
const listCache = new Map();
|
|
294
|
+
const LIST_CACHE_MAX = 50;
|
|
295
|
+
const CACHE_MS = 60_000;
|
|
296
|
+
export const GH_MAX_LIMIT = 1000;
|
|
297
|
+
export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
|
|
298
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
299
|
+
return mockGithub();
|
|
300
|
+
const capped = Math.min(Math.max(limit, 1), GH_MAX_LIMIT);
|
|
301
|
+
const hit = listCache.get(repoRoot);
|
|
302
|
+
if (!refresh && hit && Date.now() - hit.at < CACHE_MS && hit.limit >= capped) {
|
|
303
|
+
return hit.data;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
// No `comments` field — `gh … --json comments` ships full comment bodies.
|
|
307
|
+
// No `statusCheckRollup` either (#664): the CI rollup for every open PR was the
|
|
308
|
+
// dominant cost — it forced the 60 s budget below — and is now hydrated lazily per
|
|
309
|
+
// on-screen PR row via `fetchGithubChecks`. A big list still gets a little more wall
|
|
310
|
+
// clock than the default 30, but nothing like the old rollup walk.
|
|
311
|
+
const timeout = capped > 100 ? 30_000 : 15_000;
|
|
312
|
+
const fields = 'number,title,author,createdAt,labels,body,url';
|
|
313
|
+
// The repo handle first (cheap) so the counts GraphQL query — which needs owner/name —
|
|
314
|
+
// can run parallel to the two expensive list calls below.
|
|
315
|
+
const repoOut = await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], timeout);
|
|
316
|
+
const ownerName = parseOwnerName(repoOut);
|
|
317
|
+
const runGraphql = (query, variables) => {
|
|
318
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
319
|
+
for (const [key, value] of Object.entries(variables))
|
|
320
|
+
args.push('-f', `${key}=${value}`);
|
|
321
|
+
return gh(repoRoot, args, timeout);
|
|
322
|
+
};
|
|
323
|
+
// Bound the counts pagination to the rows actually being fetched: a page is 100, so
|
|
324
|
+
// `ceil(capped / 100)` pages (still capped at GH_COUNTS_MAX_PAGES) cover exactly the visible
|
|
325
|
+
// window and no more — the default 30-item load pays ONE counts round-trip, not ten. Rows
|
|
326
|
+
// beyond the window keep `comments: 0`, which the UI reads as "no badge" (same as before).
|
|
327
|
+
const countsMaxPages = Math.min(GH_COUNTS_MAX_PAGES, Math.max(1, Math.ceil(capped / 100)));
|
|
328
|
+
const [issuesOut, prsOut, counts] = await Promise.all([
|
|
329
|
+
gh(repoRoot, ['issue', 'list', '--limit', String(capped), '--json', fields], timeout),
|
|
330
|
+
gh(repoRoot, ['pr', 'list', '--limit', String(capped), '--json', `${fields},isDraft,additions,deletions`], timeout),
|
|
331
|
+
// Real comment counts (#499). Degrades to empty maps on its own — a failure here leaves
|
|
332
|
+
// every count at 0, never fails the tab. Skipped entirely if the handle isn't parseable.
|
|
333
|
+
ownerName
|
|
334
|
+
? fetchCommentCounts(runGraphql, ownerName.owner, ownerName.name, countsMaxPages)
|
|
335
|
+
: Promise.resolve({ issues: {}, prs: {} }),
|
|
336
|
+
]);
|
|
337
|
+
// One repo-wide label→color map, filled as we flatten each item's labels.
|
|
338
|
+
const labelColors = {};
|
|
339
|
+
const recordColor = (l) => {
|
|
340
|
+
if (l.color && !labelColors[l.name])
|
|
341
|
+
labelColors[l.name] = l.color;
|
|
342
|
+
};
|
|
343
|
+
const issues = z.array(ghIssueSchema).parse(JSON.parse(issuesOut)).map((i) => {
|
|
344
|
+
i.labels.forEach(recordColor);
|
|
345
|
+
return {
|
|
346
|
+
kind: 'issue',
|
|
347
|
+
number: i.number,
|
|
348
|
+
title: i.title,
|
|
349
|
+
author: i.author?.login ?? '?',
|
|
350
|
+
createdAt: i.createdAt,
|
|
351
|
+
labels: i.labels.map((l) => l.name),
|
|
352
|
+
body: (i.body ?? '').slice(0, 8_000),
|
|
353
|
+
url: i.url,
|
|
354
|
+
comments: counts.issues[i.number] ?? 0,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
const prs = z.array(ghPrSchema).parse(JSON.parse(prsOut)).map((p) => {
|
|
358
|
+
p.labels.forEach(recordColor);
|
|
359
|
+
return {
|
|
360
|
+
kind: 'pr',
|
|
361
|
+
number: p.number,
|
|
362
|
+
title: p.title,
|
|
363
|
+
author: p.author?.login ?? '?',
|
|
364
|
+
createdAt: p.createdAt,
|
|
365
|
+
labels: [...p.labels.map((l) => l.name), ...(p.isDraft ? ['draft'] : [])],
|
|
366
|
+
body: (p.body ?? '').slice(0, 8_000),
|
|
367
|
+
url: p.url,
|
|
368
|
+
comments: counts.prs[p.number] ?? 0,
|
|
369
|
+
isDraft: p.isDraft,
|
|
370
|
+
additions: p.additions,
|
|
371
|
+
deletions: p.deletions,
|
|
372
|
+
// Hydrated lazily by `fetchGithubChecks` for on-screen rows (#664) — the list no
|
|
373
|
+
// longer pays for the CI rollup of every open PR.
|
|
374
|
+
checks: null,
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
const data = {
|
|
378
|
+
available: true,
|
|
379
|
+
repo: repoOut.trim() || undefined,
|
|
380
|
+
syncedAt: new Date().toISOString(),
|
|
381
|
+
issues,
|
|
382
|
+
prs,
|
|
383
|
+
labelColors,
|
|
384
|
+
};
|
|
385
|
+
listCache.delete(repoRoot); // re-insert so this key becomes the newest
|
|
386
|
+
listCache.set(repoRoot, { at: Date.now(), limit: capped, data });
|
|
387
|
+
while (listCache.size > LIST_CACHE_MAX) {
|
|
388
|
+
const oldest = listCache.keys().next().value;
|
|
389
|
+
if (oldest === undefined)
|
|
390
|
+
break;
|
|
391
|
+
listCache.delete(oldest);
|
|
392
|
+
}
|
|
393
|
+
return data;
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
397
|
+
const reason = /ENOENT/.test(message)
|
|
398
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
399
|
+
: firstLine(message);
|
|
400
|
+
return { available: false, reason, issues: [], prs: [] };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function firstLine(s) {
|
|
404
|
+
return s.split('\n').find((l) => l.trim().length > 0)?.trim() ?? 'gh failed';
|
|
405
|
+
}
|
|
406
|
+
/** CEZ_DRY_RUN=1 — a small fixed catalog so the GitHub tab is demoable offline. */
|
|
407
|
+
function mockGithub() {
|
|
408
|
+
const mk = (over) => ({
|
|
409
|
+
author: 'mock',
|
|
410
|
+
createdAt: new Date(Date.now() - over.number * 3_600_000).toISOString(),
|
|
411
|
+
labels: [],
|
|
412
|
+
url: `https://github.com/mock/repo/${over.kind === 'pr' ? 'pull' : 'issues'}/${over.number}`,
|
|
413
|
+
comments: 0,
|
|
414
|
+
...over,
|
|
415
|
+
});
|
|
416
|
+
return {
|
|
417
|
+
available: true,
|
|
418
|
+
repo: 'mock/repo',
|
|
419
|
+
syncedAt: new Date().toISOString(),
|
|
420
|
+
issues: [
|
|
421
|
+
mk({ kind: 'issue', number: 142, title: 'Login form drops session on refresh', labels: ['bug', 'auth'], comments: 3, body: 'Repro: log in, hit reload — you land back on /login. The session cookie is set correctly, but the client store rehydrates before the cookie check resolves, so the auth guard redirects.' }),
|
|
422
|
+
mk({ kind: 'issue', number: 139, title: 'Add --json flag to cez CLI output', labels: ['enhancement', 'cli'], comments: 1, body: 'For scripting it would help if `cez list` and `cez status` could emit machine-readable JSON instead of the table view.' }),
|
|
423
|
+
mk({ kind: 'issue', number: 135, title: 'Flaky e2e: worktree cleanup race on cancel', labels: ['bug', 'flaky-test'], comments: 6, body: 'Cancelling a run while the agent holds a file lock leaves a dangling worktree. The next run on the same branch then fails with "worktree already exists".' }),
|
|
424
|
+
],
|
|
425
|
+
prs: [
|
|
426
|
+
mk({ kind: 'pr', number: 128, title: 'Fix flaky auth test in CI', labels: ['tests'], checks: 'passing', additions: 6, deletions: 3, body: 'Loosens the timing assertion in refresh.test.ts to a realistic budget.' }),
|
|
427
|
+
mk({ kind: 'pr', number: 124, title: 'Rate limit /api/runs', labels: ['server', 'draft'], isDraft: true, checks: 'failing', additions: 118, deletions: 7, comments: 4, body: 'Draft: token-bucket middleware on the runs router. Still needs the config surface and README docs before review.' }),
|
|
428
|
+
],
|
|
429
|
+
labelColors: {
|
|
430
|
+
bug: 'd73a4a',
|
|
431
|
+
auth: '5319e7',
|
|
432
|
+
enhancement: 'a2eeef',
|
|
433
|
+
cli: '0e8a16',
|
|
434
|
+
'flaky-test': 'fbca04',
|
|
435
|
+
tests: 'c5def5',
|
|
436
|
+
server: '1d76db',
|
|
437
|
+
draft: '6a737d',
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// ---- comment threads (#499 Phase 2) ----------------------------------------
|
|
442
|
+
// A lazy per-thread fetch behind `GET /api/github/comments/:kind/:number`: the
|
|
443
|
+
// conversation comments (issues endpoint — GitHub serves PR conversation
|
|
444
|
+
// comments there too), plus submitted reviews for PRs, normalized into one
|
|
445
|
+
// chronological `ForgeComment[]`. Its own bounded 60 s cache keeps an open
|
|
446
|
+
// detail view from re-fetching on every focus. Degrades to `available: false`
|
|
447
|
+
// exactly like the list fetch — never a 5xx.
|
|
448
|
+
const ghCommentUser = z.object({ login: z.string(), avatar_url: z.string().nullish() }).nullish();
|
|
449
|
+
const ghIssueCommentSchema = z.object({
|
|
450
|
+
id: z.number(),
|
|
451
|
+
user: ghCommentUser,
|
|
452
|
+
created_at: z.string(),
|
|
453
|
+
body: z.string().nullish(),
|
|
454
|
+
html_url: z.string(),
|
|
455
|
+
});
|
|
456
|
+
const ghReviewSchema = z.object({
|
|
457
|
+
id: z.number(),
|
|
458
|
+
user: ghCommentUser,
|
|
459
|
+
body: z.string().nullish(),
|
|
460
|
+
state: z.string(),
|
|
461
|
+
submitted_at: z.string().nullish(),
|
|
462
|
+
html_url: z.string(),
|
|
463
|
+
});
|
|
464
|
+
// ---- timeline events (#525) -------------------------------------------------
|
|
465
|
+
// The thread's non-comment history — commits, label changes, assignments, merges, force-pushes,
|
|
466
|
+
// cross-references. Sourced from `/issues/{n}/timeline`, which returns comments AND events in one
|
|
467
|
+
// chronological stream, so the `commented` rows keep flowing through `normalizeComments` unchanged
|
|
468
|
+
// and `comments[]` stays exactly what BACKWARD_COMPATIBILITY.md §2 promises.
|
|
469
|
+
/** The event kinds rendered in v1 — an allowlist, so a new GitHub event type is dropped rather
|
|
470
|
+
* than rendered and can never crash or clutter the thread. Real timelines carry plenty that
|
|
471
|
+
* github.com itself doesn't surface (`subscribed`, `mentioned`, `review_requested`).
|
|
472
|
+
*
|
|
473
|
+
* `reviewed` is deliberately absent: timeline `reviewed` rows DO carry a body and would work,
|
|
474
|
+
* but `/pulls/{n}/reviews` is already normalized, chipped and empty-body-filtered, so sourcing
|
|
475
|
+
* both would render every review twice. */
|
|
476
|
+
export const TIMELINE_EVENT_KINDS = new Set([
|
|
477
|
+
'committed',
|
|
478
|
+
'labeled',
|
|
479
|
+
'unlabeled',
|
|
480
|
+
'assigned',
|
|
481
|
+
'unassigned',
|
|
482
|
+
'merged',
|
|
483
|
+
'closed',
|
|
484
|
+
'reopened',
|
|
485
|
+
'head_ref_force_pushed',
|
|
486
|
+
'cross-referenced',
|
|
487
|
+
'renamed',
|
|
488
|
+
]);
|
|
489
|
+
/** Events get their OWN cap, independent of `THREAD_ENTRY_CAP`. A combined cap would mean a
|
|
490
|
+
* thread with 150 comments and 100 events returns ~120 comments — silently removing contents
|
|
491
|
+
* from a §2-protected response. */
|
|
492
|
+
export const TIMELINE_EVENT_CAP = 200;
|
|
493
|
+
/** `gh api --paginate` has no page limit, so the timeline fetch hand-rolls a bounded loop. */
|
|
494
|
+
export const TIMELINE_MAX_PAGES = 10;
|
|
495
|
+
/** ONE budget shared by every page. `gh()`'s timeout is per invocation, so ten pages at the 15 s
|
|
496
|
+
* default would put the ceiling at 150 s — an order of magnitude worse than the single
|
|
497
|
+
* `--paginate` spawn this replaces. The loop tracks a deadline and passes what's left. */
|
|
498
|
+
export const TIMELINE_BUDGET_MS = 15_000;
|
|
499
|
+
/** Never spawn a page that cannot finish. A bare `remaining <= 0` guard catches only the exact
|
|
500
|
+
* boundary; the realistic case is 300 ms left, which spawns `gh` with a 300 ms timeout, throws,
|
|
501
|
+
* and is indistinguishable from a real endpoint failure. */
|
|
502
|
+
export const TIMELINE_MIN_PAGE_MS = 2_000;
|
|
503
|
+
/** `committed` messages are trimmed to their first line, then this. */
|
|
504
|
+
const COMMIT_MESSAGE_CAP = 120;
|
|
505
|
+
/** One timeline row. `event` stays a loose `z.string()` so unknown kinds parse and are dropped by
|
|
506
|
+
* the allowlist rather than throwing the whole page. Extras are stripped by default — notably
|
|
507
|
+
* `author.email`, which is read for nothing and must not reach the wire type. */
|
|
508
|
+
export const ghTimelineEventSchema = z.object({
|
|
509
|
+
event: z.string(),
|
|
510
|
+
id: z.number().nullish(),
|
|
511
|
+
node_id: z.string().nullish(),
|
|
512
|
+
created_at: z.string().nullish(),
|
|
513
|
+
actor: z.object({ login: z.string(), avatar_url: z.string().nullish() }).nullish(),
|
|
514
|
+
url: z.string().nullish(),
|
|
515
|
+
html_url: z.string().nullish(),
|
|
516
|
+
// `committed`
|
|
517
|
+
sha: z.string().nullish(),
|
|
518
|
+
message: z.string().nullish(),
|
|
519
|
+
author: z.object({ name: z.string().nullish(), date: z.string().nullish() }).nullish(),
|
|
520
|
+
// `labeled` / `unlabeled`
|
|
521
|
+
label: z.object({ name: z.string(), color: z.string().nullish() }).nullish(),
|
|
522
|
+
// `assigned` / `unassigned`
|
|
523
|
+
assignee: z.object({ login: z.string() }).nullish(),
|
|
524
|
+
// `renamed`
|
|
525
|
+
rename: z.object({ from: z.string().nullish(), to: z.string().nullish() }).nullish(),
|
|
526
|
+
// `cross-referenced`
|
|
527
|
+
source: z
|
|
528
|
+
.object({
|
|
529
|
+
issue: z
|
|
530
|
+
.object({
|
|
531
|
+
number: z.number().nullish(),
|
|
532
|
+
title: z.string().nullish(),
|
|
533
|
+
html_url: z.string().nullish(),
|
|
534
|
+
pull_request: z.unknown().nullish(),
|
|
535
|
+
})
|
|
536
|
+
.nullish(),
|
|
537
|
+
})
|
|
538
|
+
.nullish(),
|
|
539
|
+
});
|
|
540
|
+
const REVIEW_STATE = {
|
|
541
|
+
APPROVED: 'approved',
|
|
542
|
+
CHANGES_REQUESTED: 'changes_requested',
|
|
543
|
+
COMMENTED: 'commented',
|
|
544
|
+
DISMISSED: 'dismissed',
|
|
545
|
+
};
|
|
546
|
+
/** First 200 thread entries, then `truncated`; each body sliced to 8 000 chars (same cap as
|
|
547
|
+
* item bodies). */
|
|
548
|
+
export const THREAD_ENTRY_CAP = 200;
|
|
549
|
+
const COMMENT_BODY_CAP = 8_000;
|
|
550
|
+
/** `gh api …/issues/{n}/comments` JSON → `ForgeComment[]`. Exported for unit tests. */
|
|
551
|
+
export function normalizeComments(raw) {
|
|
552
|
+
return z.array(ghIssueCommentSchema).parse(raw).map((c) => ({
|
|
553
|
+
id: c.id,
|
|
554
|
+
author: c.user?.login ?? '?',
|
|
555
|
+
avatarUrl: c.user?.avatar_url ?? undefined,
|
|
556
|
+
createdAt: c.created_at,
|
|
557
|
+
body: (c.body ?? '').slice(0, COMMENT_BODY_CAP),
|
|
558
|
+
kind: 'comment',
|
|
559
|
+
url: c.html_url,
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
/** `gh api …/pulls/{n}/reviews` JSON → `ForgeComment[]`. Reviews with an empty body AND state
|
|
563
|
+
* COMMENTED/PENDING carry no signal in a flat thread and are dropped; the rest map to
|
|
564
|
+
* `kind: 'review'` (Q4). Exported for unit tests. */
|
|
565
|
+
export function normalizeReviews(raw) {
|
|
566
|
+
return z
|
|
567
|
+
.array(ghReviewSchema)
|
|
568
|
+
.parse(raw)
|
|
569
|
+
.filter((r) => {
|
|
570
|
+
const state = r.state.toUpperCase();
|
|
571
|
+
const emptyBody = (r.body ?? '').trim().length === 0;
|
|
572
|
+
return !(emptyBody && (state === 'COMMENTED' || state === 'PENDING'));
|
|
573
|
+
})
|
|
574
|
+
.map((r) => ({
|
|
575
|
+
id: r.id,
|
|
576
|
+
author: r.user?.login ?? '?',
|
|
577
|
+
avatarUrl: r.user?.avatar_url ?? undefined,
|
|
578
|
+
createdAt: r.submitted_at ?? '',
|
|
579
|
+
body: (r.body ?? '').slice(0, COMMENT_BODY_CAP),
|
|
580
|
+
kind: 'review',
|
|
581
|
+
reviewState: REVIEW_STATE[r.state.toUpperCase()],
|
|
582
|
+
url: r.html_url,
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* `gh api …/issues/{n}/timeline` JSON → `ForgeTimelineEvent[]`, plus whether the cap fired.
|
|
587
|
+
*
|
|
588
|
+
* Returns `truncated` rather than just the array because the caller has no other way to learn it:
|
|
589
|
+
* `events.length === TIMELINE_EVENT_CAP` is ambiguous on a thread with exactly that many.
|
|
590
|
+
*
|
|
591
|
+
* Three details here are load-bearing and were verified against a real timeline, not assumed:
|
|
592
|
+
*
|
|
593
|
+
* 1. **`committed` rows return `created_at: null`** — the real timestamp is at `author.date`.
|
|
594
|
+
* Mapping `created_at` naively yields `createdAt: null` on every commit, which string-sorts to
|
|
595
|
+
* the top and silently reorders the entire thread.
|
|
596
|
+
* 2. **`committed` carries a git author, not a GitHub actor** — a name, no login, no avatar.
|
|
597
|
+
* 3. **The cap keeps the NEWEST events — `slice(-cap)`**, the opposite of the neighbouring
|
|
598
|
+
* `mergeThread`, which head-slices. The timeline arrives oldest-first, so `slice(0, cap)` would
|
|
599
|
+
* retain 200 stale day-one `labeled` rows and discard the merge and the recent commits — the
|
|
600
|
+
* exact rows #525 asks for.
|
|
601
|
+
*
|
|
602
|
+
* Exported for unit tests.
|
|
603
|
+
*/
|
|
604
|
+
export function normalizeEvents(raw, cap = TIMELINE_EVENT_CAP) {
|
|
605
|
+
const rows = z.array(ghTimelineEventSchema).parse(raw);
|
|
606
|
+
const mapped = [];
|
|
607
|
+
rows.forEach((row, index) => {
|
|
608
|
+
const kind = row.event;
|
|
609
|
+
if (!TIMELINE_EVENT_KINDS.has(kind))
|
|
610
|
+
return; // unknown//noise → dropped, never rendered
|
|
611
|
+
// Per-kind timestamp resolution — see (1) above.
|
|
612
|
+
const rawAt = kind === 'committed' ? row.author?.date : row.created_at;
|
|
613
|
+
if (!rawAt)
|
|
614
|
+
return; // no resolvable timestamp → drop, rather than merge at an arbitrary spot
|
|
615
|
+
// `author.date` arrives with a numeric offset (`+02:00`); normalize so the string compare the
|
|
616
|
+
// thread sorts by stays correct across zones.
|
|
617
|
+
const parsedAt = new Date(rawAt);
|
|
618
|
+
if (Number.isNaN(parsedAt.getTime()))
|
|
619
|
+
return;
|
|
620
|
+
const createdAt = parsedAt.toISOString();
|
|
621
|
+
// Per-kind actor resolution — see (2) above.
|
|
622
|
+
const actor = (kind === 'committed' ? row.author?.name : row.actor?.login) ?? '?';
|
|
623
|
+
// Identity: `sha` sits ahead of `node_id` deliberately. `committed` rows carry both, and a
|
|
624
|
+
// node_id-first order would key commits by an opaque `C_kwDO…` blob instead of the SHA, which
|
|
625
|
+
// is the natural, debuggable identifier and is already the rollup key. The bare-index fallback
|
|
626
|
+
// reaches only `cross-referenced`, the one kind with no identity at all — as a general scheme
|
|
627
|
+
// it would be wrong, since the id becomes the React key and an index over the post-sort array
|
|
628
|
+
// shifts for every row below an insertion, remounting them on each 60 s refetch.
|
|
629
|
+
const identity = row.id ?? row.sha ?? row.node_id ?? index;
|
|
630
|
+
const event = {
|
|
631
|
+
id: `evt-${identity}`,
|
|
632
|
+
kind,
|
|
633
|
+
actor,
|
|
634
|
+
createdAt,
|
|
635
|
+
};
|
|
636
|
+
// A git author has no avatar, so `committed` deliberately carries none.
|
|
637
|
+
if (kind !== 'committed' && row.actor?.avatar_url)
|
|
638
|
+
event.avatarUrl = row.actor.avatar_url;
|
|
639
|
+
switch (kind) {
|
|
640
|
+
case 'committed': {
|
|
641
|
+
// Enforce the full-40-hex invariant the rollup query depends on rather than assuming it:
|
|
642
|
+
// `oid` rejects anything else, and a malformed value embedded in the batched query would
|
|
643
|
+
// cost the whole chunk its glyphs instead of just this commit.
|
|
644
|
+
if (row.sha && /^[0-9a-f]{40}$/i.test(row.sha))
|
|
645
|
+
event.sha = row.sha;
|
|
646
|
+
if (row.message)
|
|
647
|
+
event.message = (row.message.split('\n')[0] ?? '').slice(0, COMMIT_MESSAGE_CAP);
|
|
648
|
+
if (row.html_url)
|
|
649
|
+
event.url = row.html_url;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
case 'labeled':
|
|
653
|
+
case 'unlabeled': {
|
|
654
|
+
if (row.label) {
|
|
655
|
+
event.label = { name: row.label.name };
|
|
656
|
+
if (row.label.color)
|
|
657
|
+
event.label.color = row.label.color;
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case 'assigned':
|
|
662
|
+
case 'unassigned': {
|
|
663
|
+
if (row.assignee?.login)
|
|
664
|
+
event.subject = row.assignee.login;
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
case 'renamed': {
|
|
668
|
+
if (row.rename?.to)
|
|
669
|
+
event.subject = row.rename.to;
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case 'cross-referenced': {
|
|
673
|
+
const issue = row.source?.issue;
|
|
674
|
+
if (issue?.number != null)
|
|
675
|
+
event.refNumber = issue.number;
|
|
676
|
+
if (issue?.title)
|
|
677
|
+
event.refTitle = issue.title;
|
|
678
|
+
if (issue)
|
|
679
|
+
event.refIsPr = Boolean(issue.pull_request);
|
|
680
|
+
if (issue?.html_url)
|
|
681
|
+
event.url = issue.html_url;
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
default:
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
mapped.push(event);
|
|
688
|
+
});
|
|
689
|
+
const truncated = mapped.length > cap;
|
|
690
|
+
// slice(-cap), NOT slice(0, cap) — see (3) above.
|
|
691
|
+
return { events: truncated ? mapped.slice(-cap) : mapped, truncated };
|
|
692
|
+
}
|
|
693
|
+
/** Merge comment/review lists chronologically (oldest first) and apply the entry cap. Exported
|
|
694
|
+
* for unit tests. */
|
|
695
|
+
export function mergeThread(parts, cap = THREAD_ENTRY_CAP) {
|
|
696
|
+
const all = parts.flat().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
697
|
+
const truncated = all.length > cap;
|
|
698
|
+
return { comments: truncated ? all.slice(0, cap) : all, truncated };
|
|
699
|
+
}
|
|
700
|
+
// Per-thread cache: keyed `repoRoot␀kind#number` (the root scopes the key — two projects each
|
|
701
|
+
// having a PR #42 must not collide; step 2.6), same 60 s TTL as the list cache but BOUNDED — a
|
|
702
|
+
// long browsing session can't grow it without limit (Map preserves insertion order → oldest
|
|
703
|
+
// first).
|
|
704
|
+
const commentsCache = new Map();
|
|
705
|
+
const COMMENTS_CACHE_MAX = 50;
|
|
706
|
+
function cacheComments(key, data) {
|
|
707
|
+
commentsCache.delete(key); // re-insert so this key becomes the newest
|
|
708
|
+
commentsCache.set(key, { at: Date.now(), data });
|
|
709
|
+
while (commentsCache.size > COMMENTS_CACHE_MAX) {
|
|
710
|
+
const oldest = commentsCache.keys().next().value;
|
|
711
|
+
if (oldest === undefined)
|
|
712
|
+
break;
|
|
713
|
+
commentsCache.delete(oldest);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** Test-only: drop the per-thread cache so cases don't leak state into each other. */
|
|
717
|
+
export function __clearCommentsCacheForTests() {
|
|
718
|
+
commentsCache.clear();
|
|
719
|
+
}
|
|
720
|
+
const TIMELINE_PER_PAGE = 100;
|
|
721
|
+
// The repo handle for the per-commit checks query (#525 Phase 2). Memoized per repoRoot — stable
|
|
722
|
+
// in practice, and keyed per root rather than globally for multi-project forward-compatibility.
|
|
723
|
+
// `null` is a cached PERMANENT negative (the slug isn't a clean two-part name, so retrying cannot
|
|
724
|
+
// help). A *thrown* gh failure is transient and deliberately NOT cached: caching it would disable
|
|
725
|
+
// glyphs until process restart on one network blip.
|
|
726
|
+
const repoHandleCache = new Map();
|
|
727
|
+
/** Test-only: drop the memoized repo handles. */
|
|
728
|
+
export function __clearRepoHandleCacheForTests() {
|
|
729
|
+
repoHandleCache.clear();
|
|
730
|
+
}
|
|
731
|
+
/** The `owner/name` for `repoRoot`, memoized. Returns null when the handle isn't a clean two-part
|
|
732
|
+
* slug or `gh` failed — the caller then skips checks entirely and commits render unglyphed. */
|
|
733
|
+
export async function resolveRepoHandle(repoRoot) {
|
|
734
|
+
const memo = repoHandleCache.get(repoRoot);
|
|
735
|
+
if (memo !== undefined)
|
|
736
|
+
return memo;
|
|
737
|
+
let handle;
|
|
738
|
+
try {
|
|
739
|
+
handle = parseOwnerName(await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']));
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// Transient — do NOT memoize, so the next thread retries.
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
repoHandleCache.set(repoRoot, handle); // includes the permanent negative
|
|
746
|
+
return handle;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Walk `/issues/{n}/timeline` under ONE shared time budget.
|
|
750
|
+
*
|
|
751
|
+
* `gh api --paginate` is not used here: it pages *"until there are no more pages of results"* and
|
|
752
|
+
* exposes no page-limit flag, so the only way to bound the walk is to hand-roll it. The cap that
|
|
753
|
+
* `paginateCounts` gets comes from being a JS cursor loop, not from a `gh` flag.
|
|
754
|
+
*
|
|
755
|
+
* The budget is a **total**, not a per-page allowance. `gh()` takes its timeout per invocation, so
|
|
756
|
+
* ten sequential spawns at the 15 s default would put the ceiling at 150 s — an order of magnitude
|
|
757
|
+
* worse than the single `--paginate` spawn this replaces. The loop tracks a deadline and passes
|
|
758
|
+
* each page whatever remains.
|
|
759
|
+
*
|
|
760
|
+
* Exported for unit tests; `run` is injected so the loop is testable without shelling out.
|
|
761
|
+
*/
|
|
762
|
+
export async function fetchTimelinePages(run, opts = {}) {
|
|
763
|
+
const maxPages = opts.maxPages ?? TIMELINE_MAX_PAGES;
|
|
764
|
+
const budgetMs = opts.budgetMs ?? TIMELINE_BUDGET_MS;
|
|
765
|
+
const minPageMs = opts.minPageMs ?? TIMELINE_MIN_PAGE_MS;
|
|
766
|
+
const now = opts.now ?? Date.now;
|
|
767
|
+
const deadline = now() + budgetMs;
|
|
768
|
+
const rows = [];
|
|
769
|
+
let stoppedShort = false;
|
|
770
|
+
let page = 1;
|
|
771
|
+
for (; page <= maxPages; page++) {
|
|
772
|
+
const remaining = deadline - now();
|
|
773
|
+
// Never spawn a page that cannot finish. A bare `remaining <= 0` guard catches only the exact
|
|
774
|
+
// boundary; the realistic case is 300 ms left, which spawns gh with a 300 ms timeout, throws,
|
|
775
|
+
// and looks indistinguishable from a real endpoint failure.
|
|
776
|
+
if (remaining < minPageMs) {
|
|
777
|
+
stoppedShort = true;
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
let parsed;
|
|
781
|
+
try {
|
|
782
|
+
parsed = z.array(z.unknown()).parse(JSON.parse(await run(page, remaining)));
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
// Page 1 rethrows so the caller's inner catch can decide whether substitution helps —
|
|
786
|
+
// nothing was fetched, so there is nothing to lose. A failure on any later page keeps the
|
|
787
|
+
// pages already in hand: discarding nine good pages to re-fetch comments-only is strictly
|
|
788
|
+
// worse than what the loop already holds.
|
|
789
|
+
if (page === 1)
|
|
790
|
+
throw err;
|
|
791
|
+
stoppedShort = true;
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
rows.push(...parsed);
|
|
795
|
+
if (parsed.length < TIMELINE_PER_PAGE)
|
|
796
|
+
break; // short page — the real end of the timeline
|
|
797
|
+
}
|
|
798
|
+
if (page > maxPages)
|
|
799
|
+
stoppedShort = true; // fell out on the page cap
|
|
800
|
+
return { rows, stoppedShort };
|
|
801
|
+
}
|
|
802
|
+
/** SHAs per rollup query. Aliases resolve independently, so a chunk that fails costs only its own
|
|
803
|
+
* glyphs — but an unbounded alias list would eventually blow the query size limit. */
|
|
804
|
+
export const COMMIT_CHECKS_CHUNK = 50;
|
|
805
|
+
/** One aliased `object(oid:)` per SHA. `oid` requires a FULL 40-char SHA in both literal and
|
|
806
|
+
* variable form (`Could not coerce value "babda63" to GitObjectID`), which constrains fixtures
|
|
807
|
+
* rather than production data — the timeline always supplies full SHAs. */
|
|
808
|
+
function commitChecksQuery(shas) {
|
|
809
|
+
const aliases = shas
|
|
810
|
+
.map((sha, i) => ` c${i}: object(oid: "${sha}") { ... on Commit { statusCheckRollup { state } } }`)
|
|
811
|
+
.join('\n');
|
|
812
|
+
return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
|
|
813
|
+
}
|
|
814
|
+
const ghCommitChecksSchema = z.record(z.string(), z.object({ statusCheckRollup: z.object({ state: z.string().nullish() }).nullish() }).nullish());
|
|
815
|
+
/**
|
|
816
|
+
* Rolled-up CI state per commit SHA, as a `sha → checks` map.
|
|
817
|
+
*
|
|
818
|
+
* Batched and aliased so a 40-commit PR costs one subprocess, not forty. Verified against the live
|
|
819
|
+
* API: each alias resolves independently and an unknown SHA comes back `null` rather than erroring
|
|
820
|
+
* the batch, so partial results degrade cleanly.
|
|
821
|
+
*
|
|
822
|
+
* Degrades to an empty map on any failure — exactly as `fetchCommentCounts` does for counts. The
|
|
823
|
+
* caller then leaves `checks` **absent**, which the UI renders as no glyph. Exported for tests;
|
|
824
|
+
* `runGraphql` is injected so this is testable without shelling out.
|
|
825
|
+
*/
|
|
826
|
+
export async function fetchCommitChecks(runGraphql, owner, name, shas, chunkSize = COMMIT_CHECKS_CHUNK) {
|
|
827
|
+
const out = {};
|
|
828
|
+
if (shas.length === 0)
|
|
829
|
+
return out;
|
|
830
|
+
for (let i = 0; i < shas.length; i += chunkSize) {
|
|
831
|
+
const chunk = shas.slice(i, i + chunkSize);
|
|
832
|
+
try {
|
|
833
|
+
const raw = JSON.parse(await runGraphql(commitChecksQuery(chunk), { owner, name }));
|
|
834
|
+
const repository = ghCommitChecksSchema.parse(raw?.data?.repository ?? {});
|
|
835
|
+
chunk.forEach((sha, index) => {
|
|
836
|
+
const node = repository[`c${index}`];
|
|
837
|
+
if (!node)
|
|
838
|
+
return; // unknown SHA → alias resolved null; leave `checks` absent
|
|
839
|
+
// Adapt the single rollup state into the array shape `rollupToChecks` expects, so the
|
|
840
|
+
// existing FAILURE/PENDING/SUCCESS vocabulary is reused rather than duplicated.
|
|
841
|
+
out[sha] = node.statusCheckRollup
|
|
842
|
+
? rollupToChecks([{ state: node.statusCheckRollup.state, status: null, conclusion: null }])
|
|
843
|
+
: null; // no CI configured — distinct from absent
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
// A failed chunk costs only its own glyphs; the rest still resolve.
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return out;
|
|
851
|
+
}
|
|
852
|
+
/** PR numbers per checks query. Aliases resolve independently (a failed chunk costs only its own
|
|
853
|
+
* glyphs); bounded so an unbounded number list can't blow the query size limit. Also the route's
|
|
854
|
+
* hard cap on how many PRs one request may ask about. */
|
|
855
|
+
export const GH_CHECKS_MAX = 100;
|
|
856
|
+
/** One aliased `pullRequest(number:)` per PR; the rolled-up CI state lives on the head commit. */
|
|
857
|
+
function prChecksQuery(numbers) {
|
|
858
|
+
const aliases = numbers
|
|
859
|
+
.map((n, i) => ` p${i}: pullRequest(number: ${n}) { commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } }`)
|
|
860
|
+
.join('\n');
|
|
861
|
+
return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
|
|
862
|
+
}
|
|
863
|
+
const ghPrChecksSchema = z.record(z.string(), z
|
|
864
|
+
.object({
|
|
865
|
+
commits: z.object({
|
|
866
|
+
nodes: z.array(z.object({
|
|
867
|
+
commit: z.object({
|
|
868
|
+
statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
|
|
869
|
+
}),
|
|
870
|
+
})),
|
|
871
|
+
}),
|
|
872
|
+
})
|
|
873
|
+
.nullish());
|
|
874
|
+
/**
|
|
875
|
+
* Rolled-up CI state per PR number, as a `number → glyph` map.
|
|
876
|
+
*
|
|
877
|
+
* Batched and aliased so a 100-PR window costs one subprocess, not a hundred. Each alias resolves
|
|
878
|
+
* independently and an unknown number comes back `null` (alias resolved null → left absent), so
|
|
879
|
+
* partial results degrade cleanly. Degrades to an empty map on any failure — exactly as
|
|
880
|
+
* `fetchCommitChecks` does. Exported for tests; `runGraphql` is injected so this is testable
|
|
881
|
+
* without shelling out.
|
|
882
|
+
*/
|
|
883
|
+
export async function fetchPrChecks(runGraphql, owner, name, numbers, chunkSize = GH_CHECKS_MAX) {
|
|
884
|
+
const out = {};
|
|
885
|
+
if (numbers.length === 0)
|
|
886
|
+
return out;
|
|
887
|
+
for (let i = 0; i < numbers.length; i += chunkSize) {
|
|
888
|
+
const chunk = numbers.slice(i, i + chunkSize);
|
|
889
|
+
try {
|
|
890
|
+
const raw = JSON.parse(await runGraphql(prChecksQuery(chunk), { owner, name }));
|
|
891
|
+
const repository = ghPrChecksSchema.parse(raw?.data?.repository ?? {});
|
|
892
|
+
chunk.forEach((number, index) => {
|
|
893
|
+
const node = repository[`p${index}`];
|
|
894
|
+
if (!node)
|
|
895
|
+
return; // unknown PR → alias resolved null; leave the glyph absent
|
|
896
|
+
const rollup = node.commits.nodes[0]?.commit.statusCheckRollup;
|
|
897
|
+
// Adapt the single rollup state into the array shape `rollupToChecks` expects, reusing the
|
|
898
|
+
// FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
|
|
899
|
+
out[number] = rollup
|
|
900
|
+
? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null
|
|
901
|
+
: null; // no CI configured — distinct from absent
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
// A failed chunk costs only its own glyphs; the rest still resolve.
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return out;
|
|
909
|
+
}
|
|
910
|
+
// Per-PR checks cache: keyed `repoRoot␀number`, same 60 s TTL as the list cache but BOUNDED. Lets
|
|
911
|
+
// a repeated visible-window hydration within a minute serve cached glyphs instead of re-querying.
|
|
912
|
+
const checksCache = new Map();
|
|
913
|
+
const CHECKS_CACHE_MAX = 500;
|
|
914
|
+
/** Test hook — the per-PR checks cache would otherwise leak state across cases in one process. */
|
|
915
|
+
export function __clearChecksCacheForTests() {
|
|
916
|
+
checksCache.clear();
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Lazy checks glyphs for the given PR numbers (route-facing). Resolves the repo handle once,
|
|
920
|
+
* serves fresh cache entries, queries only the misses, and degrades to `{ available: false,
|
|
921
|
+
* reason }` when `gh` or the handle is unavailable — never a throw, never a 5xx (plan rule 7).
|
|
922
|
+
* Numbers are de-duplicated, validated, and capped at `GH_CHECKS_MAX`.
|
|
923
|
+
*/
|
|
924
|
+
export async function fetchGithubChecks(repoRoot, numbers) {
|
|
925
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
926
|
+
return mockGithubChecks(numbers);
|
|
927
|
+
const wanted = [...new Set(numbers)].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_CHECKS_MAX);
|
|
928
|
+
const checks = {};
|
|
929
|
+
const misses = [];
|
|
930
|
+
const now = Date.now();
|
|
931
|
+
for (const n of wanted) {
|
|
932
|
+
const hit = checksCache.get(`${repoRoot}\0${n}`);
|
|
933
|
+
if (hit && now - hit.at < CACHE_MS)
|
|
934
|
+
checks[n] = hit.glyph;
|
|
935
|
+
else
|
|
936
|
+
misses.push(n);
|
|
937
|
+
}
|
|
938
|
+
if (misses.length === 0)
|
|
939
|
+
return { available: true, checks };
|
|
940
|
+
try {
|
|
941
|
+
const repoOut = await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
|
|
942
|
+
const ownerName = parseOwnerName(repoOut);
|
|
943
|
+
if (!ownerName)
|
|
944
|
+
return { available: false, reason: 'repository handle unavailable' };
|
|
945
|
+
const runGraphql = (query, variables) => {
|
|
946
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
947
|
+
for (const [key, value] of Object.entries(variables))
|
|
948
|
+
args.push('-f', `${key}=${value}`);
|
|
949
|
+
return gh(repoRoot, args);
|
|
950
|
+
};
|
|
951
|
+
const fetched = await fetchPrChecks(runGraphql, ownerName.owner, ownerName.name, misses);
|
|
952
|
+
for (const n of misses) {
|
|
953
|
+
// Absent (unknown/closed PR) caches as `null` so a nonexistent number isn't re-queried.
|
|
954
|
+
const glyph = n in fetched ? fetched[n] : null;
|
|
955
|
+
checks[n] = glyph;
|
|
956
|
+
checksCache.set(`${repoRoot}\0${n}`, { at: now, glyph });
|
|
957
|
+
}
|
|
958
|
+
while (checksCache.size > CHECKS_CACHE_MAX) {
|
|
959
|
+
const oldest = checksCache.keys().next().value;
|
|
960
|
+
if (oldest === undefined)
|
|
961
|
+
break;
|
|
962
|
+
checksCache.delete(oldest);
|
|
963
|
+
}
|
|
964
|
+
return { available: true, checks };
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
968
|
+
return {
|
|
969
|
+
available: false,
|
|
970
|
+
reason: /ENOENT/.test(message)
|
|
971
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
972
|
+
: firstLine(message),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
/** CEZ_DRY_RUN=1 — glyphs straight from the mock catalog so the offline demo shows checks. */
|
|
977
|
+
function mockGithubChecks(numbers) {
|
|
978
|
+
const byNumber = new Map(mockGithub().prs.map((p) => [p.number, (p.checks ?? null)]));
|
|
979
|
+
const checks = {};
|
|
980
|
+
for (const n of numbers)
|
|
981
|
+
checks[n] = byNumber.get(n) ?? null;
|
|
982
|
+
return { available: true, checks };
|
|
983
|
+
}
|
|
984
|
+
/** Numbers per kind in one ref-status query — the same bound, and for the same reasons, as
|
|
985
|
+
* `GH_CHECKS_MAX`: aliases resolve independently, and the query size stays finite. Taken from the
|
|
986
|
+
* contract rather than restated, because the cockpit caps its batches by the same number and a
|
|
987
|
+
* client that guessed high would have every chip in a batch 400 instead of losing its tail. */
|
|
988
|
+
export const GH_REF_STATUS_MAX = REFERENCE_STATUS_MAX;
|
|
989
|
+
/** A requested reviewer, as either connection spells them. `login` covers `User` and `Bot`, `slug`
|
|
990
|
+
* covers `Team`; `reviewerKey` folds them into one comparable string. */
|
|
991
|
+
const reviewerRefSchema = z
|
|
992
|
+
.object({
|
|
993
|
+
__typename: z.string().nullish(),
|
|
994
|
+
login: z.string().nullish(),
|
|
995
|
+
slug: z.string().nullish(),
|
|
996
|
+
})
|
|
997
|
+
.nullish();
|
|
998
|
+
/** One reviewer as a comparable key, or `null` when GitHub named nobody we can match on — which
|
|
999
|
+
* must never compare equal to another unnamed reviewer, hence `null` rather than a shared "". */
|
|
1000
|
+
function reviewerKey(ref) {
|
|
1001
|
+
const name = ref?.login ?? ref?.slug;
|
|
1002
|
+
return name ? `${ref?.__typename ?? '?'}:${name}` : null;
|
|
1003
|
+
}
|
|
1004
|
+
const ghRefStatusPrSchema = z
|
|
1005
|
+
.object({
|
|
1006
|
+
state: z.string(),
|
|
1007
|
+
isDraft: z.boolean().nullish(),
|
|
1008
|
+
reviewDecision: z.string().nullish(),
|
|
1009
|
+
/** `MERGEABLE` | `CONFLICTING` | `UNKNOWN` — GitHub computes it in the background, so
|
|
1010
|
+
* `UNKNOWN` is normal on a PR that was just pushed and must never read as "clean". */
|
|
1011
|
+
mergeable: z.string().nullish(),
|
|
1012
|
+
commits: z
|
|
1013
|
+
.object({
|
|
1014
|
+
nodes: z.array(z.object({
|
|
1015
|
+
commit: z.object({
|
|
1016
|
+
committedDate: z.string().nullish(),
|
|
1017
|
+
/** `totalCount` says whether the head is a merge; the first parent is the branch's
|
|
1018
|
+
* previous tip, which is the newest commit that is actual WORK when it is. */
|
|
1019
|
+
parents: z
|
|
1020
|
+
.object({
|
|
1021
|
+
totalCount: z.number(),
|
|
1022
|
+
nodes: z.array(z.object({ committedDate: z.string().nullish() }).nullish()).nullish(),
|
|
1023
|
+
})
|
|
1024
|
+
.nullish(),
|
|
1025
|
+
statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
|
|
1026
|
+
}),
|
|
1027
|
+
})),
|
|
1028
|
+
})
|
|
1029
|
+
.nullish(),
|
|
1030
|
+
reviews: z.object({ nodes: z.array(z.object({ submittedAt: z.string().nullish() })) }).nullish(),
|
|
1031
|
+
/** Who is on the hook RIGHT NOW. The reviewers are carried, not just the count, because a
|
|
1032
|
+
* request's date has to be matched to a request that still stands — see `reviewRequestedAt`. */
|
|
1033
|
+
reviewRequests: z
|
|
1034
|
+
.object({
|
|
1035
|
+
totalCount: z.number(),
|
|
1036
|
+
nodes: z.array(z.object({ requestedReviewer: reviewerRefSchema }).nullish()).nullish(),
|
|
1037
|
+
})
|
|
1038
|
+
.nullish(),
|
|
1039
|
+
timelineItems: z
|
|
1040
|
+
.object({
|
|
1041
|
+
nodes: z
|
|
1042
|
+
.array(z.object({ createdAt: z.string().nullish(), requestedReviewer: reviewerRefSchema }).nullish())
|
|
1043
|
+
.nullish(),
|
|
1044
|
+
})
|
|
1045
|
+
.nullish(),
|
|
1046
|
+
})
|
|
1047
|
+
.nullish();
|
|
1048
|
+
/**
|
|
1049
|
+
* When was the newest STILL-STANDING review request made?
|
|
1050
|
+
*
|
|
1051
|
+
* The two connections answer different halves and neither answers both: `reviewRequests` says who
|
|
1052
|
+
* is on the hook right now but carries no date, while a `ReviewRequestedEvent` carries the date and
|
|
1053
|
+
* survives the request being withdrawn. Reading the newest event on its own therefore dates a
|
|
1054
|
+
* request that may no longer exist — and a request added after a review and then removed would make
|
|
1055
|
+
* a live rejection read as answered, which is the exact bug the precedence above exists to fix.
|
|
1056
|
+
* Matching the two by reviewer is what keeps the date attached to a request that is really there.
|
|
1057
|
+
*
|
|
1058
|
+
* `null` when nothing matches, which the precedence reads as an undated request and treats
|
|
1059
|
+
* conservatively: the review stands rather than being dismissed on a guess.
|
|
1060
|
+
*/
|
|
1061
|
+
function standingReviewRequestedAt(requests, timeline) {
|
|
1062
|
+
const standing = new Set();
|
|
1063
|
+
for (const node of requests?.nodes ?? []) {
|
|
1064
|
+
const key = reviewerKey(node?.requestedReviewer);
|
|
1065
|
+
if (key)
|
|
1066
|
+
standing.add(key);
|
|
1067
|
+
}
|
|
1068
|
+
if (standing.size === 0)
|
|
1069
|
+
return null;
|
|
1070
|
+
let newest = null;
|
|
1071
|
+
for (const node of timeline?.nodes ?? []) {
|
|
1072
|
+
if (!node?.createdAt)
|
|
1073
|
+
continue;
|
|
1074
|
+
const key = reviewerKey(node.requestedReviewer);
|
|
1075
|
+
if (!key || !standing.has(key))
|
|
1076
|
+
continue;
|
|
1077
|
+
if (!newest || isAfter(node.createdAt, newest))
|
|
1078
|
+
newest = node.createdAt;
|
|
1079
|
+
}
|
|
1080
|
+
return newest;
|
|
1081
|
+
}
|
|
1082
|
+
const ghRefStatusIssueSchema = z.object({ state: z.string(), stateReason: z.string().nullish() }).nullish();
|
|
1083
|
+
// The aliases hold two DIFFERENT shapes, so the record stays unvalidated here and each alias is
|
|
1084
|
+
// parsed by its own schema below. A union at this level would be wrong, not merely loose: zod
|
|
1085
|
+
// strips unknown keys, so a PR node matched by the issue arm would come back with `isDraft` and
|
|
1086
|
+
// `commits` silently removed.
|
|
1087
|
+
const ghRefStatusSchema = z.record(z.string(), z.unknown());
|
|
1088
|
+
/**
|
|
1089
|
+
* One alias per NUMBER, asking `issueOrPullRequest` rather than `pullRequest`/`issue`.
|
|
1090
|
+
*
|
|
1091
|
+
* Issues and pull requests share one numbering space in a repository, so a number is exactly one
|
|
1092
|
+
* of the two and asking which is a question the forge can answer — where asking `issue(number:)`
|
|
1093
|
+
* about a pull request is a question with no answer, and GitHub says so with a NOT_FOUND *error*
|
|
1094
|
+
* rather than a null. That mattered far more than it looks: `gh api graphql` exits non-zero the
|
|
1095
|
+
* moment a response carries an `errors` array, so one mis-guessed kind used to fail the whole
|
|
1096
|
+
* batch and report every reference in it as "not found" (see `refStatusGraphql`).
|
|
1097
|
+
*
|
|
1098
|
+
* It also fixes the reference whose kind the cockpit guessed wrong — `taskReferences` infers it
|
|
1099
|
+
* from which field carried the number, and a bare `#774` can land in either — because the answer
|
|
1100
|
+
* carries `__typename` and is filed under what the number REALLY is.
|
|
1101
|
+
*
|
|
1102
|
+
* `committedDate`, the head commit's `parents.totalCount` (`first: 0` — the COUNT is the whole
|
|
1103
|
+
* question, so no parent is fetched) and the last CHANGES_REQUESTED review's `submittedAt` cost
|
|
1104
|
+
* nothing extra, riding the same node, and are what let the precedence tell a review the author has
|
|
1105
|
+
* already responded to from one still about the code on screen. The last `ReviewRequestedEvent` is
|
|
1106
|
+
* asked for the same reason: `reviewRequests` says only THAT someone is on the hook, never since
|
|
1107
|
+
* when, and the difference between a request made before the review and one made after it is the
|
|
1108
|
+
* difference between a reviewer who has not looked yet and an author who has answered.
|
|
1109
|
+
*
|
|
1110
|
+
* `mergeable` rides it too, and is the reason the batch can answer "this one conflicts" without
|
|
1111
|
+
* the per-PR probe `prMergeState` runs for the merge box. It is NOT folded into the status: see
|
|
1112
|
+
* `conflicts` in the contract for why the two stay separate axes.
|
|
1113
|
+
*/
|
|
1114
|
+
function refStatusQuery(numbers) {
|
|
1115
|
+
const aliases = numbers
|
|
1116
|
+
.map((n, i) => ` r${i}: issueOrPullRequest(number: ${n}) { __typename ... on PullRequest { state isDraft reviewDecision mergeable commits(last: 1) { nodes { commit { committedDate parents(first: 1) { totalCount nodes { committedDate } } statusCheckRollup { state } } } } reviews(last: 1, states: CHANGES_REQUESTED) { nodes { submittedAt } } reviewRequests(first: 20) { totalCount nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { slug } } } } timelineItems(last: 20, itemTypes: [REVIEW_REQUESTED_EVENT]) { nodes { ... on ReviewRequestedEvent { createdAt requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { slug } } } } } } ... on Issue { state stateReason } }`)
|
|
1117
|
+
.join('\n');
|
|
1118
|
+
return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* The one place a PR's signals collapse into a single word.
|
|
1122
|
+
*
|
|
1123
|
+
* The question the chip answers is "what is this waiting on RIGHT NOW", so the ranking is by
|
|
1124
|
+
* freshness of the signal, not by how heavy a blocker it is:
|
|
1125
|
+
*
|
|
1126
|
+
* Read the ranking as **whose move is it**, which is the question a table is scanned for:
|
|
1127
|
+
*
|
|
1128
|
+
* 1. `merged` / `closed` — nobody's. Terminal, whatever checks or reviews say.
|
|
1129
|
+
* 2. `draft` — the author's, and they have said so themselves.
|
|
1130
|
+
* 3. `checks-pending` — the machine's. CI running means a commit was JUST pushed, the newest
|
|
1131
|
+
* thing that has happened to this PR, so it outranks a requested change unconditionally.
|
|
1132
|
+
* 4. `changes-requested` — the AUTHOR's, and only while that is still true: the review must be
|
|
1133
|
+
* about the code that is there now, with no re-review already asked for.
|
|
1134
|
+
* 5. `checks-failing` — the author's again. A reviewer cannot approve a red PR anyway.
|
|
1135
|
+
* 6. `review-required` — the REVIEWER's: they have been asked, or the author has answered and
|
|
1136
|
+
* the merge is now blocked on someone coming back to look.
|
|
1137
|
+
* 7. `ready` — open, not a draft, nothing failing, nothing running, nobody waited on.
|
|
1138
|
+
*
|
|
1139
|
+
* The subtle one is (4) → (6), and it is not a preference but a data finding. `reviewDecision`
|
|
1140
|
+
* stays `CHANGES_REQUESTED` after the author has responded — GitHub does not clear it until a
|
|
1141
|
+
* reviewer submits again — so on its own it points at the author forever. Two signals say the ball
|
|
1142
|
+
* has moved back:
|
|
1143
|
+
*
|
|
1144
|
+
* - **a review request made AFTER the review**, which is the author clicking re-request.
|
|
1145
|
+
* Authoritative, and observed live alongside a stale `CHANGES_REQUESTED` and an EMPTY
|
|
1146
|
+
* `latestReviews` — the case that has no other tell.
|
|
1147
|
+
* - **a non-merge commit newer than the review**, the fallback for an author who pushed without
|
|
1148
|
+
* clicking anything. Merges are excluded because GitHub's "Update branch" button writes one,
|
|
1149
|
+
* dated now, that answers nothing — the reflexive click on a stale PR must not clear a
|
|
1150
|
+
* rejection.
|
|
1151
|
+
*
|
|
1152
|
+
* The "after" in the first one is load-bearing, and its absence was a reported bug. A request that
|
|
1153
|
+
* PREDATES the review is a reviewer who has not looked yet, and on a PR where several people were
|
|
1154
|
+
* asked and one of them rejected it the others stay listed forever — so a bare
|
|
1155
|
+
* `reviewRequests.totalCount > 0` reads a live "changes requested" as answered and hides it behind
|
|
1156
|
+
* "waiting for review" (observed live: three reviewers asked at 10:28, changes requested
|
|
1157
|
+
* the next day, one reviewer still pending).
|
|
1158
|
+
*
|
|
1159
|
+
* Either way the words change from "you owe edits" to "they owe a look", and so does the colour:
|
|
1160
|
+
* danger is the author's move, info is the reviewer's.
|
|
1161
|
+
*
|
|
1162
|
+
* Both timestamps are optional and their ABSENCE is conservative: with no dates to compare and no
|
|
1163
|
+
* re-request, a review counts as current, and the chip keeps pointing at the author.
|
|
1164
|
+
*
|
|
1165
|
+
* `reviewDecision` is null on a repo with no review policy, which is exactly why `ready` is not
|
|
1166
|
+
* spelled "approved": on such a repo a green PR IS ready, and no approval will ever arrive — but a
|
|
1167
|
+
* requested reviewer still moves it to `review-required`, because someone was explicitly asked.
|
|
1168
|
+
*/
|
|
1169
|
+
export function derivePrReferenceStatus(pr) {
|
|
1170
|
+
const state = pr.state.toUpperCase();
|
|
1171
|
+
if (state === 'MERGED')
|
|
1172
|
+
return 'merged';
|
|
1173
|
+
if (state === 'CLOSED')
|
|
1174
|
+
return 'closed';
|
|
1175
|
+
if (pr.isDraft)
|
|
1176
|
+
return 'draft';
|
|
1177
|
+
if (pr.checks === 'pending')
|
|
1178
|
+
return 'checks-pending';
|
|
1179
|
+
const decision = (pr.reviewDecision ?? '').toUpperCase();
|
|
1180
|
+
const changesRequested = decision === 'CHANGES_REQUESTED';
|
|
1181
|
+
// Has the author already answered the review — by asking for another look, or by pushing?
|
|
1182
|
+
// A standing request counts as the ask only if it POSTDATES the review; one made before it is a
|
|
1183
|
+
// reviewer who has not got to the PR yet, and on a PR where someone else rejected it that
|
|
1184
|
+
// request would otherwise mask the rejection indefinitely. With no review date at all it still
|
|
1185
|
+
// counts — that is the empty-`reviews` case above, where it is the only signal there is.
|
|
1186
|
+
const reRequested = pr.reviewRequested === true && (!pr.changesRequestedAt || isAfter(pr.reviewRequestedAt, pr.changesRequestedAt));
|
|
1187
|
+
// A push counts as the answer, judged by the newest commit that is actual WORK. GitHub's "Update
|
|
1188
|
+
// branch" button (and this cockpit's own "Resolve conflicts") writes `Merge branch 'main' into
|
|
1189
|
+
// <branch>` dated NOW, newer than any review while addressing none of it: the click people make
|
|
1190
|
+
// reflexively on a stale PR must not wipe a rejection off the chip. Two parents is what tells
|
|
1191
|
+
// that commit apart from work.
|
|
1192
|
+
//
|
|
1193
|
+
// Which is why a merge is not simply DISQUALIFYING: it is transparent. Reading only the head
|
|
1194
|
+
// would let a merge landing on top of a genuine fix erase that fix's answer and flip the chip
|
|
1195
|
+
// back to red, blaming an author who already responded — the same misattribution this whole
|
|
1196
|
+
// function exists to prevent, just pointed the other way. The merge's FIRST parent is the
|
|
1197
|
+
// branch's previous tip, so it carries the date of the work the merge sat on top of.
|
|
1198
|
+
const workCommittedAt = (pr.headParentCount ?? 1) < 2 ? pr.headCommittedAt : pr.headFirstParentCommittedAt;
|
|
1199
|
+
const pushed = isAfter(workCommittedAt, pr.changesRequestedAt);
|
|
1200
|
+
const answered = reRequested || pushed;
|
|
1201
|
+
if (changesRequested && !answered)
|
|
1202
|
+
return 'changes-requested';
|
|
1203
|
+
if (pr.checks === 'failing')
|
|
1204
|
+
return 'checks-failing';
|
|
1205
|
+
// `APPROVED` is the forge saying the review requirement IS MET, and it outranks a pending
|
|
1206
|
+
// request: a reviewer left on the list after someone else approved is a courtesy ask, not an
|
|
1207
|
+
// unmet gate. (A repo needing two approvals reports `REVIEW_REQUIRED` until it has both, so
|
|
1208
|
+
// `APPROVED` never arrives early.) Without this, an approved, green, mergeable pull request
|
|
1209
|
+
// reads "waiting for review" for as long as anyone stays listed — which is indefinitely.
|
|
1210
|
+
if (decision === 'APPROVED')
|
|
1211
|
+
return 'ready';
|
|
1212
|
+
if (changesRequested || decision === 'REVIEW_REQUIRED' || pr.reviewRequested === true) {
|
|
1213
|
+
return 'review-required';
|
|
1214
|
+
}
|
|
1215
|
+
return 'ready';
|
|
1216
|
+
}
|
|
1217
|
+
export function mergeabilityOf(state, mergeable) {
|
|
1218
|
+
if (state.toUpperCase() !== 'OPEN')
|
|
1219
|
+
return undefined;
|
|
1220
|
+
switch (mergeable?.toUpperCase()) {
|
|
1221
|
+
case 'MERGEABLE':
|
|
1222
|
+
return 'mergeable';
|
|
1223
|
+
case 'CONFLICTING':
|
|
1224
|
+
return 'conflicting';
|
|
1225
|
+
default:
|
|
1226
|
+
// Includes a field GitHub omitted entirely: not being told is not being told.
|
|
1227
|
+
return 'unknown';
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/** Did `later` happen AFTER `earlier` — a commit, or a re-request, landing past the review?
|
|
1231
|
+
* Unparseable or missing dates answer `false`, which is the conservative direction for both
|
|
1232
|
+
* callers: they only ever use a `true` to demote a review, so no answer must keep the review
|
|
1233
|
+
* current rather than silently dismiss one. */
|
|
1234
|
+
function isAfter(later, earlier) {
|
|
1235
|
+
const a = later ? Date.parse(later) : NaN;
|
|
1236
|
+
const b = earlier ? Date.parse(earlier) : NaN;
|
|
1237
|
+
return Number.isFinite(a) && Number.isFinite(b) && a > b;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* An issue's two signals as one word. `NOT_PLANNED` is kept apart from `completed` because they
|
|
1241
|
+
* are opposite outcomes — "we did it" vs "we won't" — and a task whose issue was declined must
|
|
1242
|
+
* not read as a task that landed.
|
|
1243
|
+
*/
|
|
1244
|
+
export function deriveIssueReferenceStatus(issue) {
|
|
1245
|
+
if (issue.state.toUpperCase() !== 'CLOSED')
|
|
1246
|
+
return 'open';
|
|
1247
|
+
return (issue.stateReason ?? '').toUpperCase() === 'NOT_PLANNED' ? 'not-planned' : 'completed';
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Status per NUMBER, resolved to whatever that number actually is.
|
|
1251
|
+
*
|
|
1252
|
+
* Batched and aliased like `fetchPrChecks`: one subprocess per chunk, each alias resolving
|
|
1253
|
+
* independently, and a number the forge does not have simply staying absent from `resolved` (its
|
|
1254
|
+
* chip then renders neutral, as it did before this seam existed). A failed chunk costs only its
|
|
1255
|
+
* own numbers, and says so rather than letting them look absent. Exported for tests; `runGraphql`
|
|
1256
|
+
* is injected so this is testable without shelling out.
|
|
1257
|
+
*/
|
|
1258
|
+
export async function fetchRefStatuses(runGraphql, owner, name, numbers, chunkSize = GH_REF_STATUS_MAX) {
|
|
1259
|
+
const out = { resolved: {}, failed: [] };
|
|
1260
|
+
if (numbers.length === 0)
|
|
1261
|
+
return out;
|
|
1262
|
+
for (let i = 0; i < numbers.length; i += chunkSize) {
|
|
1263
|
+
const chunk = numbers.slice(i, i + chunkSize);
|
|
1264
|
+
try {
|
|
1265
|
+
const raw = JSON.parse(await runGraphql(refStatusQuery(chunk), { owner, name }));
|
|
1266
|
+
const repository = ghRefStatusSchema.parse(raw?.data?.repository ?? {});
|
|
1267
|
+
chunk.forEach((number, index) => {
|
|
1268
|
+
const node = repository[`r${index}`];
|
|
1269
|
+
// Absent alias → the number is not in this repository at all. Left out of the map, which
|
|
1270
|
+
// is what the route reports as "not found" and the chip paints as neutral.
|
|
1271
|
+
if (!node)
|
|
1272
|
+
return;
|
|
1273
|
+
if (node.__typename === 'PullRequest') {
|
|
1274
|
+
const pr = ghRefStatusPrSchema.parse(node);
|
|
1275
|
+
if (!pr)
|
|
1276
|
+
return;
|
|
1277
|
+
const head = pr.commits?.nodes[0]?.commit;
|
|
1278
|
+
const rollup = head?.statusCheckRollup;
|
|
1279
|
+
const mergeability = mergeabilityOf(pr.state, pr.mergeable);
|
|
1280
|
+
out.resolved[number] = {
|
|
1281
|
+
kind: 'pr',
|
|
1282
|
+
status: derivePrReferenceStatus({
|
|
1283
|
+
state: pr.state,
|
|
1284
|
+
isDraft: pr.isDraft,
|
|
1285
|
+
reviewDecision: pr.reviewDecision,
|
|
1286
|
+
// Adapt the single rollup state into the array shape `rollupToChecks` expects,
|
|
1287
|
+
// reusing the FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
|
|
1288
|
+
checks: rollup ? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null : null,
|
|
1289
|
+
headCommittedAt: head?.committedDate,
|
|
1290
|
+
// Two parents = "Update branch", which is dated now and answers nothing on its own —
|
|
1291
|
+
// so the first parent's date, the work it sat on top of, is carried with it.
|
|
1292
|
+
headParentCount: head?.parents?.totalCount,
|
|
1293
|
+
headFirstParentCommittedAt: head?.parents?.nodes?.[0]?.committedDate,
|
|
1294
|
+
// `reviews(last: 1, states: CHANGES_REQUESTED)` — the timestamp only. WHETHER changes
|
|
1295
|
+
// are requested stays `reviewDecision`'s answer, which is the one that accounts for
|
|
1296
|
+
// dismissed and superseded reviews.
|
|
1297
|
+
changesRequestedAt: pr.reviews?.nodes[0]?.submittedAt,
|
|
1298
|
+
reviewRequested: (pr.reviewRequests?.totalCount ?? 0) > 0,
|
|
1299
|
+
// WHEN that standing request was made. `reviewRequests` carries no date of its own,
|
|
1300
|
+
// and without one an old request looks exactly like a re-request.
|
|
1301
|
+
reviewRequestedAt: standingReviewRequestedAt(pr.reviewRequests, pr.timelineItems),
|
|
1302
|
+
}),
|
|
1303
|
+
// The tri-state, not a boolean: `unknown` has to survive as far as the cache, which
|
|
1304
|
+
// is what decides to ask again in seconds rather than in a minute.
|
|
1305
|
+
...(mergeability ? { mergeable: mergeability } : {}),
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
else if (node.__typename === 'Issue') {
|
|
1309
|
+
const issue = ghRefStatusIssueSchema.parse(node);
|
|
1310
|
+
if (!issue)
|
|
1311
|
+
return;
|
|
1312
|
+
out.resolved[number] = { kind: 'issue', status: deriveIssueReferenceStatus(issue) };
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
catch (err) {
|
|
1317
|
+
// A failed chunk costs only its own numbers; the rest still resolve. They are recorded as
|
|
1318
|
+
// FAILED rather than left absent, so nothing downstream mistakes them for "no such number".
|
|
1319
|
+
out.failed.push(...chunk);
|
|
1320
|
+
out.reason ??= firstLine(err instanceof Error ? err.message : String(err));
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return out;
|
|
1324
|
+
}
|
|
1325
|
+
// Per-reference cache: keyed `repoRoot␀number` — by NUMBER, not by kind, because the kind is now
|
|
1326
|
+
// something the forge answers rather than something the caller asserts. Same 60 s TTL and bounded
|
|
1327
|
+
// shape as the checks cache; `null` is a cached "this repository has no such number", so a
|
|
1328
|
+
// transcript-scraped number from another repo is not re-queried on every table repaint.
|
|
1329
|
+
//
|
|
1330
|
+
// `unknownSince` is when this reference FIRST came back with its mergeability still being
|
|
1331
|
+
// computed, carried across refreshes so the fast recheck below is bounded to that first window
|
|
1332
|
+
// rather than restarting on every answer that is still `unknown`.
|
|
1333
|
+
const refStatusCache = new Map();
|
|
1334
|
+
const REF_STATUS_CACHE_MAX = 500;
|
|
1335
|
+
/** Test-only: drop the per-reference cache so cases don't leak state into each other. */
|
|
1336
|
+
export function __clearRefStatusCacheForTests() {
|
|
1337
|
+
refStatusCache.clear();
|
|
1338
|
+
}
|
|
1339
|
+
/** Test-only: warm the cache the way the lazy route would have, so a reader can be tested
|
|
1340
|
+
* without a forge behind it. */
|
|
1341
|
+
export function __seedRefStatusCacheForTests(repoRoot, entries) {
|
|
1342
|
+
for (const [number, resolved] of entries) {
|
|
1343
|
+
refStatusCache.set(refStatusKey(repoRoot, number), { at: Date.now(), resolved });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Forget what we knew about one reference, so the next read asks GitHub again.
|
|
1348
|
+
*
|
|
1349
|
+
* Called where cezar itself CHANGES a pull request — it merges one, it opens one — because those
|
|
1350
|
+
* are the only forge changes this process can know about without asking. Everything else has to
|
|
1351
|
+
* be polled (GitHub cannot push to a cockpit with no public endpoint), but waiting out a TTL to
|
|
1352
|
+
* notice our own merge is a self-inflicted staleness: for up to a minute every chip would keep
|
|
1353
|
+
* showing the pre-merge status of a pull request the user watched this server merge.
|
|
1354
|
+
*
|
|
1355
|
+
* Deleting rather than overwriting with a guessed `merged`: the forge is the authority on what a
|
|
1356
|
+
* reference is, and a mutation that reports success is still not the same as having read the
|
|
1357
|
+
* result. The next reader pays one query and gets the truth — after which the answer is `merged`,
|
|
1358
|
+
* `recheckAfterMs` goes null, and the cockpit stops polling that batch entirely. Invalidating here
|
|
1359
|
+
* therefore REDUCES long-run traffic rather than adding to it.
|
|
1360
|
+
*/
|
|
1361
|
+
export function forgetRefStatus(repoRoot, number) {
|
|
1362
|
+
refStatusCache.delete(refStatusKey(repoRoot, number));
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Everything the cache ALREADY knows about these numbers. Never spawns `gh`, never awaits.
|
|
1366
|
+
*
|
|
1367
|
+
* This is what lets a status ride along with the rows that carry the references, instead of the
|
|
1368
|
+
* cockpit fetching it separately a moment later: the run index reads whatever is warm and ships
|
|
1369
|
+
* it, and a cold entry is simply absent — the lazy `/github/ref-status` route stays the thing that
|
|
1370
|
+
* actually goes and asks.
|
|
1371
|
+
*
|
|
1372
|
+
* Because it cannot cost anything, the caller may pass a SUPERSET of the numbers it will really
|
|
1373
|
+
* display. That matters: deciding which of a run's references a chip shows is the cockpit's rule
|
|
1374
|
+
* (#407, #526), deliberately not duplicated server-side, and a cache read does not need to know —
|
|
1375
|
+
* it can look up every number a run mentions and let the client pick.
|
|
1376
|
+
*/
|
|
1377
|
+
export function readCachedRefStatuses(repoRoot, numbers) {
|
|
1378
|
+
const out = { prs: {}, issues: {} };
|
|
1379
|
+
const now = Date.now();
|
|
1380
|
+
for (const number of new Set(numbers)) {
|
|
1381
|
+
const hit = refStatusCache.get(refStatusKey(repoRoot, number));
|
|
1382
|
+
if (!hit || !hit.resolved || now - hit.at >= refStatusTtl(hit.resolved, hit.unknownSince, now))
|
|
1383
|
+
continue;
|
|
1384
|
+
out[hit.resolved.kind === 'pr' ? 'prs' : 'issues'][number] = hit.resolved.status;
|
|
1385
|
+
}
|
|
1386
|
+
return out;
|
|
1387
|
+
}
|
|
1388
|
+
/** The `#N` in a forge URL — `…/pull/774` → 774. Null when the tail is not a number, so a URL
|
|
1389
|
+
* shape we do not recognise invalidates nothing rather than inventing a key. */
|
|
1390
|
+
export function refNumberFromUrl(url) {
|
|
1391
|
+
const last = /\/(\d+)\/?$/.exec(url.trim());
|
|
1392
|
+
const parsed = last ? Number(last[1]) : Number.NaN;
|
|
1393
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
1394
|
+
}
|
|
1395
|
+
/** A closed issue or an abandoned PR can be REOPENED, so this is long rather than forever — but
|
|
1396
|
+
* it is the rare event, and re-asking a hundred settled references every minute to catch it is
|
|
1397
|
+
* the wrong trade. */
|
|
1398
|
+
const REF_STATUS_CLOSED_TTL = 10 * 60_000;
|
|
1399
|
+
/** A merged pull request is merged forever: GitHub has no un-merge. Capped at a day only so a
|
|
1400
|
+
* long-lived server eventually re-reads rather than trusting a value from another era. */
|
|
1401
|
+
const REF_STATUS_MERGED_TTL = 24 * 60 * 60_000;
|
|
1402
|
+
/**
|
|
1403
|
+
* How long a cached answer stays fresh — by how changeable that answer IS.
|
|
1404
|
+
*
|
|
1405
|
+
* One TTL for everything gets both ends wrong: it re-asks about a merged PR (which cannot change)
|
|
1406
|
+
* every minute, and it is the only thing standing between a running CI job and a stale chip. What
|
|
1407
|
+
* a reader wants rechecked is precisely what is still moving.
|
|
1408
|
+
*
|
|
1409
|
+
* A number the repository does not have keeps the short TTL: it is usually a wrong number, but it
|
|
1410
|
+
* is also what a reference to a not-yet-created PR looks like, and re-asking is cheap.
|
|
1411
|
+
*/
|
|
1412
|
+
function refStatusTtl(entry, unknownSince, now = Date.now()) {
|
|
1413
|
+
if (!entry)
|
|
1414
|
+
return CACHE_MS;
|
|
1415
|
+
// Mergeability GitHub has not finished computing is not an answer to cache for a minute. It is
|
|
1416
|
+
// the normal reply for the first seconds after a push, and holding it that long is what let a
|
|
1417
|
+
// conflicting pull request read "Ready to merge" until the page was reloaded. Ask again in
|
|
1418
|
+
// seconds instead — and only while it is still plausibly being computed, so a repository that
|
|
1419
|
+
// answers `UNKNOWN` indefinitely settles back to the ordinary cadence rather than spawning `gh`
|
|
1420
|
+
// every few seconds forever.
|
|
1421
|
+
if (entry.mergeable === 'unknown' &&
|
|
1422
|
+
unknownSince !== undefined &&
|
|
1423
|
+
now - unknownSince < MERGEABILITY_UNKNOWN_WINDOW_MS) {
|
|
1424
|
+
return MERGEABILITY_UNKNOWN_TTL_MS;
|
|
1425
|
+
}
|
|
1426
|
+
switch (entry.status) {
|
|
1427
|
+
case 'merged':
|
|
1428
|
+
return REF_STATUS_MERGED_TTL;
|
|
1429
|
+
case 'closed':
|
|
1430
|
+
case 'completed':
|
|
1431
|
+
case 'not-planned':
|
|
1432
|
+
return REF_STATUS_CLOSED_TTL;
|
|
1433
|
+
default:
|
|
1434
|
+
return CACHE_MS;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
/** How long a status can be trusted to stay put — `null` when it can never change again. The
|
|
1438
|
+
* cadence half of `refStatusTtl`, and deliberately the same function: a value the cache would
|
|
1439
|
+
* still be serving is a value there is no point asking for, and a value it would NOT serve —
|
|
1440
|
+
* mergeability still being computed — is one the cockpit should come back for just as soon. */
|
|
1441
|
+
function refStatusRecheckAfter(entry, unknownSince, now = Date.now()) {
|
|
1442
|
+
if (entry?.status === 'merged')
|
|
1443
|
+
return null; // GitHub has no un-merge
|
|
1444
|
+
return refStatusTtl(entry, unknownSince, now);
|
|
1445
|
+
}
|
|
1446
|
+
/** How long the WHOLE answer holds — the soonest any single reference in it could differ. `null`
|
|
1447
|
+
* only when every one of them is immutable, which is what tells the cockpit to stop scheduling.
|
|
1448
|
+
* Taking the per-reference values rather than the entries, because one of them may be on the fast
|
|
1449
|
+
* mergeability cadence and the batch has to travel at the speed of its most impatient member. */
|
|
1450
|
+
function batchRecheckAfter(rechecks) {
|
|
1451
|
+
let soonest = null;
|
|
1452
|
+
for (const after of rechecks) {
|
|
1453
|
+
if (after === null)
|
|
1454
|
+
continue;
|
|
1455
|
+
soonest = soonest === null ? after : Math.min(soonest, after);
|
|
1456
|
+
}
|
|
1457
|
+
return soonest;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* How long a still-computing mergeability holds, and for how long that fast cadence applies.
|
|
1461
|
+
*
|
|
1462
|
+
* Five seconds because that is the shape of the thing being waited for: GitHub kicks off the
|
|
1463
|
+
* merge-base computation when asked and usually has it by the next request. Bounded to a minute
|
|
1464
|
+
* because a value that is STILL unknown after that is not a computation in flight any more — it is
|
|
1465
|
+
* a repository that will not answer, and re-asking it every five seconds forever costs a `gh`
|
|
1466
|
+
* subprocess a second for nothing.
|
|
1467
|
+
*/
|
|
1468
|
+
const MERGEABILITY_UNKNOWN_TTL_MS = 5_000;
|
|
1469
|
+
const MERGEABILITY_UNKNOWN_WINDOW_MS = 60_000;
|
|
1470
|
+
/** A forge that could not be reached is worth retrying, and worth not hammering: a workspace with
|
|
1471
|
+
* no `gh` installed would otherwise spawn a subprocess a minute, forever, to be told the same
|
|
1472
|
+
* thing. Five minutes is the same order as the cockpit's own reconnect cadence. */
|
|
1473
|
+
const REF_STATUS_RETRY_MS = 5 * 60_000;
|
|
1474
|
+
/**
|
|
1475
|
+
* GraphQL through `gh`, tolerating a PARTIALLY failed response.
|
|
1476
|
+
*
|
|
1477
|
+
* `gh api graphql` exits non-zero whenever the reply carries an `errors` array — even when `data`
|
|
1478
|
+
* is fully populated for every alias that DID resolve. With one alias per reference that is not an
|
|
1479
|
+
* edge case, it is the normal case: a single number that no longer exists (or never did, having
|
|
1480
|
+
* been scraped from a transcript that named another repository) makes GitHub answer
|
|
1481
|
+
* `{data: {...everything else...}, errors: [NOT_FOUND]}` and `gh` exit 1.
|
|
1482
|
+
*
|
|
1483
|
+
* `execFile` rejects on a non-zero exit, so that used to throw away a whole batch's worth of
|
|
1484
|
+
* perfectly good statuses and report every reference in it as "not found" — including, in the bug
|
|
1485
|
+
* that produced this function, an open pull request in the project's own repository.
|
|
1486
|
+
*
|
|
1487
|
+
* So: a non-zero exit whose stdout still carries a usable `data.repository` is a partial success
|
|
1488
|
+
* and is used as-is. Anything else — no stdout, unparseable stdout, or a null `repository`, which
|
|
1489
|
+
* means the repo handle itself did not resolve — rethrows, so a real failure still degrades to
|
|
1490
|
+
* `{available: false}` instead of masquerading as "none of these exist".
|
|
1491
|
+
*/
|
|
1492
|
+
function refStatusGraphql(repoRoot) {
|
|
1493
|
+
return async (query, variables) => {
|
|
1494
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
1495
|
+
for (const [key, value] of Object.entries(variables))
|
|
1496
|
+
args.push('-f', `${key}=${value}`);
|
|
1497
|
+
try {
|
|
1498
|
+
return await gh(repoRoot, args);
|
|
1499
|
+
}
|
|
1500
|
+
catch (err) {
|
|
1501
|
+
const stdout = err.stdout;
|
|
1502
|
+
if (typeof stdout === 'string' && hasResolvedRepository(stdout))
|
|
1503
|
+
return stdout;
|
|
1504
|
+
throw err;
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* The repo handle, memoized in the same map `resolveRepoHandle` uses — but rethrowing instead of
|
|
1510
|
+
* answering `null`.
|
|
1511
|
+
*
|
|
1512
|
+
* The memo is the point: without it every recheck tick spawned a second subprocess to re-learn an
|
|
1513
|
+
* `owner/name` that changes approximately never, doubling the cost of the one query that actually
|
|
1514
|
+
* carries information. The difference from `resolveRepoHandle` is the failure: this route turns a
|
|
1515
|
+
* `gh` error into the payload's `reason` — "gh CLI not found — install it and run `gh auth login`"
|
|
1516
|
+
* is what the chip's tooltip now shows a user — and a `null` would erase which failure it was.
|
|
1517
|
+
*/
|
|
1518
|
+
async function resolveRepoHandleStrict(repoRoot) {
|
|
1519
|
+
const memo = repoHandleCache.get(repoRoot);
|
|
1520
|
+
if (memo !== undefined)
|
|
1521
|
+
return memo;
|
|
1522
|
+
// A throw here is transient and deliberately NOT memoized, exactly as in `resolveRepoHandle`.
|
|
1523
|
+
const handle = parseOwnerName(await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']));
|
|
1524
|
+
repoHandleCache.set(repoRoot, handle); // includes the permanent negative
|
|
1525
|
+
return handle;
|
|
1526
|
+
}
|
|
1527
|
+
/** Is this a reply we can still read aliases out of? `repository: null` says the handle did not
|
|
1528
|
+
* resolve, which is a real failure and must not be read as "the numbers are all missing". */
|
|
1529
|
+
function hasResolvedRepository(stdout) {
|
|
1530
|
+
try {
|
|
1531
|
+
const parsed = JSON.parse(stdout);
|
|
1532
|
+
const repository = parsed?.data?.repository;
|
|
1533
|
+
return typeof repository === 'object' && repository !== null;
|
|
1534
|
+
}
|
|
1535
|
+
catch {
|
|
1536
|
+
return false;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Route-facing reference status. Resolves the repo handle once, serves fresh cache entries,
|
|
1541
|
+
* queries only the misses, and degrades to `{ available: false, reason }` when `gh` or the handle
|
|
1542
|
+
* is unavailable — never a throw, never a 5xx.
|
|
1543
|
+
*
|
|
1544
|
+
* The two request lists are merged into ONE set of numbers: issues and pull requests share a
|
|
1545
|
+
* repository's numbering space, so a number is one thing and asking about it twice would be asking
|
|
1546
|
+
* the same question twice. What comes back is filed by what each number turned out to BE, which is
|
|
1547
|
+
* why a chip whose kind the cockpit guessed wrong still gets the right status.
|
|
1548
|
+
*/
|
|
1549
|
+
export async function fetchGithubRefStatus(repoRoot, input) {
|
|
1550
|
+
const asPrs = sanitizeRefNumbers(input.prs);
|
|
1551
|
+
const asIssues = sanitizeRefNumbers(input.issues);
|
|
1552
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
1553
|
+
return mockGithubRefStatus(asPrs, asIssues);
|
|
1554
|
+
const wanted = [...new Set([...asPrs, ...asIssues])];
|
|
1555
|
+
const resolved = { prs: {}, issues: {} };
|
|
1556
|
+
// How soon each reference in the answer could differ — what the batch's cadence is the minimum
|
|
1557
|
+
// of. Per reference rather than per batch because mergeability still being computed is worth
|
|
1558
|
+
// coming back for in seconds while everything else holds for a minute.
|
|
1559
|
+
const rechecks = [];
|
|
1560
|
+
// The second axis, and a list rather than a map because it is nearly always empty: only the
|
|
1561
|
+
// pull requests the forge actively called CONFLICTING are named (see `mergeabilityOf`).
|
|
1562
|
+
const conflicts = [];
|
|
1563
|
+
const file = (number, entry, unknownSince) => {
|
|
1564
|
+
rechecks.push(refStatusRecheckAfter(entry, unknownSince));
|
|
1565
|
+
if (!entry)
|
|
1566
|
+
return;
|
|
1567
|
+
resolved[entry.kind === 'pr' ? 'prs' : 'issues'][number] = entry.status;
|
|
1568
|
+
if (entry.mergeable === 'conflicting')
|
|
1569
|
+
conflicts.push(number);
|
|
1570
|
+
};
|
|
1571
|
+
const misses = [];
|
|
1572
|
+
const now = Date.now();
|
|
1573
|
+
for (const n of wanted) {
|
|
1574
|
+
const hit = refStatusCache.get(refStatusKey(repoRoot, n));
|
|
1575
|
+
if (!hit || now - hit.at >= refStatusTtl(hit.resolved, hit.unknownSince, now))
|
|
1576
|
+
misses.push(n);
|
|
1577
|
+
else
|
|
1578
|
+
file(n, hit.resolved, hit.unknownSince);
|
|
1579
|
+
}
|
|
1580
|
+
if (misses.length === 0) {
|
|
1581
|
+
return {
|
|
1582
|
+
available: true,
|
|
1583
|
+
prs: resolved.prs,
|
|
1584
|
+
issues: resolved.issues,
|
|
1585
|
+
conflicts,
|
|
1586
|
+
recheckAfterMs: batchRecheckAfter(rechecks),
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
const ownerName = await resolveRepoHandleStrict(repoRoot);
|
|
1591
|
+
if (!ownerName) {
|
|
1592
|
+
return { available: false, reason: 'repository handle unavailable', recheckAfterMs: REF_STATUS_RETRY_MS };
|
|
1593
|
+
}
|
|
1594
|
+
const batch = await fetchRefStatuses(refStatusGraphql(repoRoot), ownerName.owner, ownerName.name, misses);
|
|
1595
|
+
const failed = new Set(batch.failed);
|
|
1596
|
+
// Stamped when the answer ARRIVED, not when the request was assembled: `now` above predates
|
|
1597
|
+
// the round trip, and dating an entry by it would age a slow query's results by its own
|
|
1598
|
+
// duration — shortening the TTL of exactly the answers that cost the most to get.
|
|
1599
|
+
const storedAt = Date.now();
|
|
1600
|
+
for (const n of misses) {
|
|
1601
|
+
// A number we could not ask about is NOT cached: caching it would pin "this repository has
|
|
1602
|
+
// no such number" for a minute on the strength of a network blip.
|
|
1603
|
+
if (failed.has(n))
|
|
1604
|
+
continue;
|
|
1605
|
+
const entry = batch.resolved[n] ?? null;
|
|
1606
|
+
// Kept from the previous answer, not restarted: the fast cadence is bounded from when this
|
|
1607
|
+
// reference FIRST came back still-computing, so a forge that never resolves it cannot hold
|
|
1608
|
+
// the batch on a five-second poll indefinitely.
|
|
1609
|
+
const unknownSince = entry?.mergeable === 'unknown'
|
|
1610
|
+
? (refStatusCache.get(refStatusKey(repoRoot, n))?.unknownSince ?? storedAt)
|
|
1611
|
+
: undefined;
|
|
1612
|
+
file(n, entry, unknownSince);
|
|
1613
|
+
refStatusCache.set(refStatusKey(repoRoot, n), {
|
|
1614
|
+
at: storedAt,
|
|
1615
|
+
resolved: entry,
|
|
1616
|
+
...(unknownSince === undefined ? {} : { unknownSince }),
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
while (refStatusCache.size > REF_STATUS_CACHE_MAX) {
|
|
1620
|
+
const oldest = refStatusCache.keys().next().value;
|
|
1621
|
+
if (oldest === undefined)
|
|
1622
|
+
break;
|
|
1623
|
+
refStatusCache.delete(oldest);
|
|
1624
|
+
}
|
|
1625
|
+
// Anything unasked makes the whole answer `unavailable`, deliberately. The alternative is a
|
|
1626
|
+
// payload where a number we could not reach is indistinguishable from one that does not exist,
|
|
1627
|
+
// and the cockpit would paint "not found on this repository" over a perfectly good PR — the
|
|
1628
|
+
// exact defect this route was reported for. The successes are cached either way, so the next
|
|
1629
|
+
// request costs only the numbers that failed and usually answers in full.
|
|
1630
|
+
if (failed.size > 0) {
|
|
1631
|
+
return {
|
|
1632
|
+
available: false,
|
|
1633
|
+
reason: batch.reason ?? 'GitHub could not be reached',
|
|
1634
|
+
recheckAfterMs: REF_STATUS_RETRY_MS,
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
available: true,
|
|
1639
|
+
prs: resolved.prs,
|
|
1640
|
+
issues: resolved.issues,
|
|
1641
|
+
conflicts,
|
|
1642
|
+
recheckAfterMs: batchRecheckAfter(rechecks),
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
catch (err) {
|
|
1646
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1647
|
+
return {
|
|
1648
|
+
available: false,
|
|
1649
|
+
reason: /ENOENT/.test(message)
|
|
1650
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
1651
|
+
: firstLine(message),
|
|
1652
|
+
recheckAfterMs: REF_STATUS_RETRY_MS,
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
/** NUL separator, as everywhere else here: two projects each having a #42 must not collide. */
|
|
1657
|
+
function refStatusKey(repoRoot, number) {
|
|
1658
|
+
return `${repoRoot}\0#${number}`;
|
|
1659
|
+
}
|
|
1660
|
+
function sanitizeRefNumbers(numbers) {
|
|
1661
|
+
return [...new Set(numbers ?? [])].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_REF_STATUS_MAX);
|
|
1662
|
+
}
|
|
1663
|
+
/** CEZ_DRY_RUN=1 — statuses derived from the mock catalog so the offline demo paints real chips. */
|
|
1664
|
+
function mockGithubRefStatus(prs, issues) {
|
|
1665
|
+
const catalog = mockGithub();
|
|
1666
|
+
const byPr = new Map(catalog.prs.map((p) => [p.number, p]));
|
|
1667
|
+
const byIssue = new Set(catalog.issues.map((i) => i.number));
|
|
1668
|
+
const out = { available: true, prs: {}, issues: {}, recheckAfterMs: CACHE_MS };
|
|
1669
|
+
for (const n of prs) {
|
|
1670
|
+
const pr = byPr.get(n);
|
|
1671
|
+
if (pr) {
|
|
1672
|
+
out.prs[n] = derivePrReferenceStatus({
|
|
1673
|
+
state: 'OPEN',
|
|
1674
|
+
isDraft: pr.isDraft,
|
|
1675
|
+
reviewDecision: null,
|
|
1676
|
+
checks: (pr.checks ?? null),
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
for (const n of issues)
|
|
1681
|
+
if (byIssue.has(n))
|
|
1682
|
+
out.issues[n] = 'open';
|
|
1683
|
+
return out;
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
|
|
1687
|
+
* filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades:
|
|
1688
|
+
* gh missing/offline → `{ available: false, reason }`, a 404 → a "not found" hint — never a throw.
|
|
1689
|
+
*
|
|
1690
|
+
* Since #525 the thread is sourced from `/issues/{n}/timeline`, which returns comments AND events
|
|
1691
|
+
* in one stream: `commented` rows go through the unchanged `normalizeComments`, the rest through
|
|
1692
|
+
* `normalizeEvents`. `comments[]` therefore keeps its exact pre-#525 shape, contents and cap
|
|
1693
|
+
* (BACKWARD_COMPATIBILITY.md §2) — see the top-up below for the one case that needed defending.
|
|
1694
|
+
*/
|
|
1695
|
+
export async function fetchGithubComments(repoRoot, kind, number, refresh = false) {
|
|
1696
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
1697
|
+
return mockGithubComments(kind);
|
|
1698
|
+
// NUL separator: cannot appear in a filesystem path, so roots can never alias.
|
|
1699
|
+
const key = `${repoRoot}\0${kind}#${number}`;
|
|
1700
|
+
const hit = commentsCache.get(key);
|
|
1701
|
+
if (!refresh && hit && Date.now() - hit.at < CACHE_MS)
|
|
1702
|
+
return hit.data;
|
|
1703
|
+
// PR conversation comments live on the issues endpoint too — always use it for the body thread.
|
|
1704
|
+
const legacyComments = () => gh(repoRoot, ['api', `repos/{owner}/{repo}/issues/${number}/comments`, '--paginate']);
|
|
1705
|
+
try {
|
|
1706
|
+
let commentRows = [];
|
|
1707
|
+
let events;
|
|
1708
|
+
let eventsTruncated = false;
|
|
1709
|
+
let stoppedShort = false;
|
|
1710
|
+
try {
|
|
1711
|
+
const pages = await fetchTimelinePages((page, timeoutMs) => gh(repoRoot, [
|
|
1712
|
+
'api',
|
|
1713
|
+
'-H',
|
|
1714
|
+
'Accept: application/vnd.github+json',
|
|
1715
|
+
`repos/{owner}/{repo}/issues/${number}/timeline?per_page=${TIMELINE_PER_PAGE}&page=${page}`,
|
|
1716
|
+
], timeoutMs));
|
|
1717
|
+
stoppedShort = pages.stoppedShort;
|
|
1718
|
+
commentRows = pages.rows.filter((r) => r?.event === 'commented');
|
|
1719
|
+
const normalized = normalizeEvents(pages.rows);
|
|
1720
|
+
events = normalized.events;
|
|
1721
|
+
eventsTruncated = normalized.truncated;
|
|
1722
|
+
// Comments top-up. The two ENTRY caps are independent, but the FETCH budget is not: today's
|
|
1723
|
+
// call is an unbounded --paginate over a comments-only endpoint, so every comment is fetched
|
|
1724
|
+
// and mergeThread picks the oldest 200 from the complete set. Under the timeline those same
|
|
1725
|
+
// 200 slots are filled from at most 1000 rows in which events compete with comments — so on
|
|
1726
|
+
// a 1500-row thread with 250 interleaved comments, comments[] would return ~167 where today
|
|
1727
|
+
// it returns 200. Same §2 defect class as a combined entry cap, arriving via the source set.
|
|
1728
|
+
//
|
|
1729
|
+
// The trigger deliberately over-fires: it cannot distinguish "comments were cut off" from
|
|
1730
|
+
// "this thread just has few comments", because the second is only knowable by fetching them.
|
|
1731
|
+
// So on an event-heavy thread with 30 comments it re-fetches 30 complete comments. Accepted
|
|
1732
|
+
// — it fires only past ~1000 timeline rows, behind the 60 s LRU, and the alternative is a §2
|
|
1733
|
+
// regression. In the other direction the threshold is provably sound: with ≥200 commented
|
|
1734
|
+
// rows already in the oldest-first prefix, mergeThread's oldest-200 cut lies inside it.
|
|
1735
|
+
if (stoppedShort && commentRows.length < THREAD_ENTRY_CAP) {
|
|
1736
|
+
// Its OWN nested catch. The inner handler's remedy is the fallback — which is this very
|
|
1737
|
+
// call — and the outer handler returns { available: false, comments: [] }, emptying a
|
|
1738
|
+
// thread that was about to render. So a thrown top-up is swallowed: keep the commented
|
|
1739
|
+
// rows the timeline already returned and carry on. That is at worst the short list the
|
|
1740
|
+
// safeguard existed to avoid, never worse than not having attempted it.
|
|
1741
|
+
try {
|
|
1742
|
+
commentRows = z.array(z.unknown()).parse(JSON.parse(await legacyComments()));
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
// keep the timeline's own commented rows
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
catch (timelineErr) {
|
|
1750
|
+
// Scoped INSIDE the existing outer catch on purpose: the outer handler's /404|not found/i
|
|
1751
|
+
// branch would otherwise turn a timeline 404 into an empty thread. ENOENT is the one failure
|
|
1752
|
+
// the fallback cannot rescue — nothing will work, and a second spawn fails identically.
|
|
1753
|
+
const message = timelineErr instanceof Error ? timelineErr.message : String(timelineErr);
|
|
1754
|
+
if (/ENOENT/.test(message))
|
|
1755
|
+
throw timelineErr;
|
|
1756
|
+
// Every other endpoint-level failure substitutes the legacy comments call. It is a
|
|
1757
|
+
// substitution, not a retry — a different endpoint, which typically still answers. (A 403
|
|
1758
|
+
// attaches to the token rather than the endpoint, so it cannot succeed either; not
|
|
1759
|
+
// special-cased, since rate-limit replies are immediate and it costs one fast spawn.)
|
|
1760
|
+
commentRows = z.array(z.unknown()).parse(JSON.parse(await legacyComments()));
|
|
1761
|
+
events = undefined;
|
|
1762
|
+
}
|
|
1763
|
+
// Per-commit CI (#525 Phase 2). One extra subprocess per opened thread that contains commits,
|
|
1764
|
+
// behind the same 60 s LRU — a per-thread-open cost, not per-render. Every failure path here
|
|
1765
|
+
// leaves `checks` ABSENT rather than null, so commits simply render unglyphed: the fetch
|
|
1766
|
+
// degrades to "no data" and the render decides what absence looks like.
|
|
1767
|
+
const commitShas = (events ?? []).flatMap((e) => (e.kind === 'committed' && e.sha ? [e.sha] : []));
|
|
1768
|
+
if (commitShas.length > 0) {
|
|
1769
|
+
const handle = await resolveRepoHandle(repoRoot);
|
|
1770
|
+
if (handle) {
|
|
1771
|
+
const runGraphql = (query, variables) => {
|
|
1772
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
1773
|
+
for (const [k, v] of Object.entries(variables))
|
|
1774
|
+
args.push('-f', `${k}=${v}`);
|
|
1775
|
+
return gh(repoRoot, args);
|
|
1776
|
+
};
|
|
1777
|
+
const checks = await fetchCommitChecks(runGraphql, handle.owner, handle.name, commitShas);
|
|
1778
|
+
for (const event of events ?? []) {
|
|
1779
|
+
if (event.kind !== 'committed' || !event.sha)
|
|
1780
|
+
continue;
|
|
1781
|
+
if (event.sha in checks)
|
|
1782
|
+
event.checks = checks[event.sha];
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
const parts = [normalizeComments(commentRows)];
|
|
1787
|
+
if (kind === 'pr') {
|
|
1788
|
+
const reviewsOut = await gh(repoRoot, ['api', `repos/{owner}/{repo}/pulls/${number}/reviews`, '--paginate']);
|
|
1789
|
+
parts.push(normalizeReviews(JSON.parse(reviewsOut)));
|
|
1790
|
+
}
|
|
1791
|
+
const { comments, truncated } = mergeThread(parts);
|
|
1792
|
+
const data = {
|
|
1793
|
+
available: true,
|
|
1794
|
+
comments,
|
|
1795
|
+
// OR-folded exactly as before, so the pre-existing >200-comments trigger is preserved
|
|
1796
|
+
// rather than replaced.
|
|
1797
|
+
truncated: truncated || eventsTruncated || stoppedShort || undefined,
|
|
1798
|
+
};
|
|
1799
|
+
if (events)
|
|
1800
|
+
data.events = events;
|
|
1801
|
+
cacheComments(key, data);
|
|
1802
|
+
return data;
|
|
1803
|
+
}
|
|
1804
|
+
catch (err) {
|
|
1805
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1806
|
+
const reason = /ENOENT/.test(message)
|
|
1807
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
1808
|
+
: /404|not found/i.test(message)
|
|
1809
|
+
? 'not found on GitHub — it may be closed or deleted'
|
|
1810
|
+
: firstLine(message);
|
|
1811
|
+
return { available: false, reason, comments: [] };
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/** CEZ_DRY_RUN=1 — a small fixed thread (one image-bearing comment, plus a review for PRs) so
|
|
1815
|
+
* the whole feature is demoable and e2e-testable offline. */
|
|
1816
|
+
function mockGithubComments(kind) {
|
|
1817
|
+
const base = Date.now() - 3_600_000;
|
|
1818
|
+
const at = (offset) => new Date(base + offset).toISOString();
|
|
1819
|
+
const comments = [
|
|
1820
|
+
{
|
|
1821
|
+
id: 1,
|
|
1822
|
+
author: 'ada',
|
|
1823
|
+
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4',
|
|
1824
|
+
createdAt: at(0),
|
|
1825
|
+
body: 'Thanks for the report — I can reproduce. Which browser were you on?\n\n```\nchrome 126, macOS\n```',
|
|
1826
|
+
kind: 'comment',
|
|
1827
|
+
url: 'https://github.com/mock/repo/issues/1#issuecomment-1',
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
id: 2,
|
|
1831
|
+
author: 'lin',
|
|
1832
|
+
createdAt: at(600_000),
|
|
1833
|
+
body: 'Here is the failing screen:\n\n',
|
|
1834
|
+
kind: 'comment',
|
|
1835
|
+
url: 'https://github.com/mock/repo/issues/1#issuecomment-2',
|
|
1836
|
+
},
|
|
1837
|
+
];
|
|
1838
|
+
if (kind === 'pr') {
|
|
1839
|
+
comments.push({
|
|
1840
|
+
id: 3,
|
|
1841
|
+
author: 'grace',
|
|
1842
|
+
avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4',
|
|
1843
|
+
createdAt: at(1_200_000),
|
|
1844
|
+
body: 'Looks good overall — please add a regression test before this lands.',
|
|
1845
|
+
kind: 'review',
|
|
1846
|
+
reviewState: 'changes_requested',
|
|
1847
|
+
url: 'https://github.com/mock/repo/pull/1#pullrequestreview-3',
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
// Timeline events (#525) so the whole feature is demoable and e2e-testable offline. Deliberately
|
|
1851
|
+
// covers the cases that are easy to get wrong rather than one of each: a multi-commit run by ONE
|
|
1852
|
+
// author (exercises the client-side grouping) with MIXED check states (passing/failing/pending
|
|
1853
|
+
// plus one `null` = no CI configured), a label change, a cross-reference, and — for PRs — a
|
|
1854
|
+
// merge. SHAs are full 40-char because the rollup query's `oid` rejects abbreviated ones, and a
|
|
1855
|
+
// fixture that cheated there would not survive being pasted into a real query.
|
|
1856
|
+
const sha = (seed) => seed.repeat(40).slice(0, 40);
|
|
1857
|
+
const events = [
|
|
1858
|
+
{
|
|
1859
|
+
id: 'evt-100',
|
|
1860
|
+
kind: 'labeled',
|
|
1861
|
+
actor: 'ada',
|
|
1862
|
+
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4',
|
|
1863
|
+
createdAt: at(300_000),
|
|
1864
|
+
label: { name: 'bug', color: 'd73a4a' },
|
|
1865
|
+
},
|
|
1866
|
+
{
|
|
1867
|
+
id: `evt-${sha('a')}`,
|
|
1868
|
+
kind: 'committed',
|
|
1869
|
+
actor: 'Lin Zhao',
|
|
1870
|
+
createdAt: at(900_000),
|
|
1871
|
+
sha: sha('a'),
|
|
1872
|
+
message: 'fix(session): keep the refresh token on reload',
|
|
1873
|
+
checks: 'passing',
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
id: `evt-${sha('b')}`,
|
|
1877
|
+
kind: 'committed',
|
|
1878
|
+
actor: 'Lin Zhao',
|
|
1879
|
+
createdAt: at(960_000),
|
|
1880
|
+
sha: sha('b'),
|
|
1881
|
+
message: 'test(session): cover the reload path',
|
|
1882
|
+
checks: 'failing',
|
|
1883
|
+
},
|
|
1884
|
+
{
|
|
1885
|
+
id: `evt-${sha('c')}`,
|
|
1886
|
+
kind: 'committed',
|
|
1887
|
+
actor: 'Lin Zhao',
|
|
1888
|
+
createdAt: at(1_020_000),
|
|
1889
|
+
sha: sha('c'),
|
|
1890
|
+
message: 'chore: appease the linter',
|
|
1891
|
+
checks: 'pending',
|
|
1892
|
+
},
|
|
1893
|
+
{
|
|
1894
|
+
id: `evt-${sha('d')}`,
|
|
1895
|
+
kind: 'committed',
|
|
1896
|
+
actor: 'Lin Zhao',
|
|
1897
|
+
createdAt: at(1_080_000),
|
|
1898
|
+
sha: sha('d'),
|
|
1899
|
+
message: 'docs: note the new behavior',
|
|
1900
|
+
checks: null, // no CI configured — renders no glyph, distinct from absent
|
|
1901
|
+
},
|
|
1902
|
+
{
|
|
1903
|
+
id: 'evt-101',
|
|
1904
|
+
kind: 'cross-referenced',
|
|
1905
|
+
actor: 'grace',
|
|
1906
|
+
avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4',
|
|
1907
|
+
createdAt: at(1_500_000),
|
|
1908
|
+
refNumber: 42,
|
|
1909
|
+
refTitle: 'Session handling rewrite',
|
|
1910
|
+
refIsPr: true,
|
|
1911
|
+
url: 'https://github.com/mock/repo/pull/42',
|
|
1912
|
+
},
|
|
1913
|
+
];
|
|
1914
|
+
if (kind === 'pr') {
|
|
1915
|
+
events.push({
|
|
1916
|
+
id: 'evt-102',
|
|
1917
|
+
kind: 'merged',
|
|
1918
|
+
actor: 'grace',
|
|
1919
|
+
avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4',
|
|
1920
|
+
createdAt: at(1_800_000),
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
return { available: true, comments, events };
|
|
1924
|
+
}
|
|
1925
|
+
// ---- draft-PR creation (review gate, spec 009) ------------------------------
|
|
1926
|
+
// Final autosave-commit → `git push -u origin cez/<id8>` → `gh pr create
|
|
1927
|
+
// --draft`, all executed in the task worktree (gh picks the repo up from the
|
|
1928
|
+
// worktree's remote). Every failure maps to a one-line human error — the GUI
|
|
1929
|
+
// shows it as a toast plus the manual `git merge <branch>` fallback. Never throws.
|
|
1930
|
+
const PR_URL_RE = /https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/;
|
|
1931
|
+
const PUSH_TIMEOUT_MS = 60_000;
|
|
1932
|
+
const PROGRESS_LINES_MAX = 10;
|
|
1933
|
+
export async function createDraftPr(input) {
|
|
1934
|
+
const { run } = input;
|
|
1935
|
+
const worktree = run.worktreePath;
|
|
1936
|
+
const branch = run.branch;
|
|
1937
|
+
if (!worktree || !branch) {
|
|
1938
|
+
return { ok: false, error: 'this task has no worktree/branch to publish' };
|
|
1939
|
+
}
|
|
1940
|
+
// Final autosave: the branch must hold everything before it leaves the box.
|
|
1941
|
+
// This is the LAST flush — unlike the turn-end and run-finalize ones there is
|
|
1942
|
+
// no later autosave to pick the work up, so a refusal (conflicted tree) or a
|
|
1943
|
+
// failed commit has to stop the publish instead of silently opening a PR from
|
|
1944
|
+
// a branch that is missing the run's final state.
|
|
1945
|
+
const saved = await autosaveCommit(worktree, 'pre-PR');
|
|
1946
|
+
if (saved === 'refused') {
|
|
1947
|
+
return {
|
|
1948
|
+
ok: false,
|
|
1949
|
+
error: 'worktree has unresolved merge conflicts — resolve them, then publish again',
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
if (saved === 'failed') {
|
|
1953
|
+
return { ok: false, error: 'could not commit the final changes — check git status in the worktree' };
|
|
1954
|
+
}
|
|
1955
|
+
// DRY-RUN (CEZ_DRY_RUN=1): no push, no gh — simulate success with a fake PR
|
|
1956
|
+
// URL so the whole review → PR flow is testable without GitHub.
|
|
1957
|
+
if (process.env.CEZ_DRY_RUN === '1') {
|
|
1958
|
+
return { ok: true, url: 'https://github.com/open-mercato/demo/pull/777', dryRun: true };
|
|
1959
|
+
}
|
|
1960
|
+
const remote = await execTool(['remote', 'get-url', 'origin'], worktree, 'git');
|
|
1961
|
+
if (!remote.ok || !remote.stdout.trim()) {
|
|
1962
|
+
return { ok: false, error: 'no git remote — add one (git remote add origin <url>) or merge the branch locally' };
|
|
1963
|
+
}
|
|
1964
|
+
const push = await execTool(['push', '-u', 'origin', branch], worktree, 'git', PUSH_TIMEOUT_MS);
|
|
1965
|
+
if (!push.ok) {
|
|
1966
|
+
return { ok: false, error: `git push failed — ${tail(push.stderr) || 'unknown error'}` };
|
|
1967
|
+
}
|
|
1968
|
+
const body = buildPrBody(input.handoffText, run.task);
|
|
1969
|
+
// Target the branch the worktree forked from (config `baseBranch`) — without
|
|
1970
|
+
// --base, gh aims at the repo default (main) even when work started on
|
|
1971
|
+
// develop. `origin/x` normalizes to `x`; a raw sha (detached-HEAD fork
|
|
1972
|
+
// point) can't be a PR base, so gh falls back to the default branch.
|
|
1973
|
+
const prBase = run.baseBranch?.replace(/^origin\//, '');
|
|
1974
|
+
const baseArgs = prBase && !/^[0-9a-f]{7,40}$/i.test(prBase) ? ['--base', prBase] : [];
|
|
1975
|
+
const pr = await execTool(['pr', 'create', '--draft', '--head', branch, ...baseArgs, '--title', run.title, '--body', body], worktree, 'gh', PUSH_TIMEOUT_MS);
|
|
1976
|
+
if (!pr.ok) {
|
|
1977
|
+
if (pr.notFound) {
|
|
1978
|
+
return { ok: false, error: 'gh not found — install the GitHub CLI and run `gh auth login`, or merge the branch locally' };
|
|
1979
|
+
}
|
|
1980
|
+
const hint = /auth|log ?in|credential/i.test(pr.stderr) ? ' (try `gh auth login`)' : '';
|
|
1981
|
+
return { ok: false, error: `gh pr create failed — ${tail(pr.stderr) || 'unknown error'}${hint}` };
|
|
1982
|
+
}
|
|
1983
|
+
// gh prints the PR URL on stdout; some versions echo it to stderr instead.
|
|
1984
|
+
const match = PR_URL_RE.exec(`${pr.stdout}\n${pr.stderr}`);
|
|
1985
|
+
if (!match) {
|
|
1986
|
+
return { ok: false, error: 'gh pr create returned no PR URL — check `gh pr list` manually' };
|
|
1987
|
+
}
|
|
1988
|
+
return { ok: true, url: match[0], dryRun: false };
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* PR body from the handoff journal: the "## Goal" section (task text as
|
|
1992
|
+
* fallback) + the first ~10 lines of "## Progress log" (newest first) +
|
|
1993
|
+
* the cezar footer.
|
|
1994
|
+
*/
|
|
1995
|
+
export function buildPrBody(handoffText, task) {
|
|
1996
|
+
const goal = section(handoffText, '## Goal') || task.trim();
|
|
1997
|
+
const progress = section(handoffText, '## Progress log')
|
|
1998
|
+
.split('\n')
|
|
1999
|
+
.filter((l) => l.trim())
|
|
2000
|
+
.slice(0, PROGRESS_LINES_MAX)
|
|
2001
|
+
.join('\n');
|
|
2002
|
+
const parts = ['## Goal', '', goal];
|
|
2003
|
+
if (progress)
|
|
2004
|
+
parts.push('', '## Progress log', '', progress);
|
|
2005
|
+
parts.push('', '---', '', '🤖 made with cezar');
|
|
2006
|
+
return parts.join('\n');
|
|
2007
|
+
}
|
|
2008
|
+
/** Text of one `## Header` section, up to the next `## ` header. */
|
|
2009
|
+
function section(text, header) {
|
|
2010
|
+
const start = text.indexOf(`${header}\n`);
|
|
2011
|
+
if (start < 0)
|
|
2012
|
+
return '';
|
|
2013
|
+
const rest = text.slice(start + header.length + 1);
|
|
2014
|
+
const next = rest.indexOf('\n## ');
|
|
2015
|
+
return (next >= 0 ? rest.slice(0, next) : rest).trim();
|
|
2016
|
+
}
|
|
2017
|
+
/** Last 3 stderr lines, pipe-joined — enough context, toast-sized. */
|
|
2018
|
+
function tail(stderr) {
|
|
2019
|
+
return stderr.trim().split('\n').slice(-3).join(' | ').slice(0, 300);
|
|
2020
|
+
}
|
|
2021
|
+
function execTool(args, cwd, bin, timeoutMs = 30_000) {
|
|
2022
|
+
return new Promise((resolve) => {
|
|
2023
|
+
execFile(bin, args, { cwd, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' }, (err, stdout, stderr) => resolve({
|
|
2024
|
+
ok: !err,
|
|
2025
|
+
stdout: stdout ?? '',
|
|
2026
|
+
stderr: stderr ?? '',
|
|
2027
|
+
notFound: err?.code === 'ENOENT',
|
|
2028
|
+
}));
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
// ---- the driver -------------------------------------------------------------
|
|
2032
|
+
/** Cached availability probe so `GET /api/health` never pays a full listing. */
|
|
2033
|
+
let detectCache = null;
|
|
2034
|
+
async function detectGithub(repoRoot) {
|
|
2035
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
2036
|
+
return { available: true };
|
|
2037
|
+
if (detectCache && detectCache.repoRoot === repoRoot && Date.now() - detectCache.at < CACHE_MS) {
|
|
2038
|
+
return detectCache.result;
|
|
2039
|
+
}
|
|
2040
|
+
let result;
|
|
2041
|
+
try {
|
|
2042
|
+
await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner'], 5_000);
|
|
2043
|
+
result = { available: true };
|
|
2044
|
+
}
|
|
2045
|
+
catch (err) {
|
|
2046
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2047
|
+
result = {
|
|
2048
|
+
available: false,
|
|
2049
|
+
reason: /ENOENT/.test(message)
|
|
2050
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
2051
|
+
: firstLine(message),
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
detectCache = { at: Date.now(), repoRoot, result };
|
|
2055
|
+
return result;
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Non-blocking availability for `GET /api/health` (#major-health-latency): serves the last-known
|
|
2059
|
+
* probe immediately (stale-while-revalidate) and only returns `null` on a cold start, before the
|
|
2060
|
+
* first probe has ever warmed the cache. It NEVER shells out to `gh` on the request that reads it,
|
|
2061
|
+
* so health stays under the bookmarklet's 800 ms port budget (a `gh repo view` round-trip is
|
|
2062
|
+
* ~500–650 ms on its own). `null` is contract-safe — the whole `forge` field is additive, so
|
|
2063
|
+
* "unknown until warm" is a valid answer.
|
|
2064
|
+
*
|
|
2065
|
+
* Serving the stale value while revalidating is what keeps the GitHub nav item from flickering:
|
|
2066
|
+
* without it, every time the 60 s cache expired this returned `null` for one 5 s health poll,
|
|
2067
|
+
* dropping `forge.available` and blinking the sidebar item out until the background probe warmed.
|
|
2068
|
+
*/
|
|
2069
|
+
export function detectGithubCached(repoRoot) {
|
|
2070
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
2071
|
+
return { available: true };
|
|
2072
|
+
const cached = detectCache && detectCache.repoRoot === repoRoot ? detectCache.result : null;
|
|
2073
|
+
const fresh = detectCache && detectCache.repoRoot === repoRoot && Date.now() - detectCache.at < CACHE_MS;
|
|
2074
|
+
if (!fresh) {
|
|
2075
|
+
void detectGithub(repoRoot).catch(() => { }); // revalidate off the request path
|
|
2076
|
+
}
|
|
2077
|
+
return cached; // last-known value while revalidating; null only until the first probe warms
|
|
2078
|
+
}
|
|
2079
|
+
const mergeStateCache = new Map();
|
|
2080
|
+
const mergeInflight = new Set();
|
|
2081
|
+
const MERGE_CACHE_MS = 15_000;
|
|
2082
|
+
function mergeCheckState(check) {
|
|
2083
|
+
const value = (check.conclusion || check.state || check.status || '').toUpperCase();
|
|
2084
|
+
if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(value))
|
|
2085
|
+
return 'passing';
|
|
2086
|
+
if (['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'CANCELLED'].includes(value))
|
|
2087
|
+
return 'failing';
|
|
2088
|
+
if (['PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED', 'WAITING', 'REQUESTED'].includes(value))
|
|
2089
|
+
return 'pending';
|
|
2090
|
+
return 'unknown';
|
|
2091
|
+
}
|
|
2092
|
+
export function normalizeMergeState(raw, policyRaw, requirements = {
|
|
2093
|
+
readable: false,
|
|
2094
|
+
requiredChecks: [],
|
|
2095
|
+
}) {
|
|
2096
|
+
const pr = mergePrSchema.parse(raw);
|
|
2097
|
+
const policy = repoMergePolicySchema.parse(policyRaw);
|
|
2098
|
+
const state = pr.state.toUpperCase() === 'MERGED' ? 'merged' : pr.state.toUpperCase() === 'CLOSED' ? 'closed' : 'open';
|
|
2099
|
+
const mergeable = pr.mergeable?.toUpperCase() === 'MERGEABLE'
|
|
2100
|
+
? 'mergeable'
|
|
2101
|
+
: pr.mergeable?.toUpperCase() === 'CONFLICTING'
|
|
2102
|
+
? 'conflicting'
|
|
2103
|
+
: 'unknown';
|
|
2104
|
+
const reviewDecision = pr.reviewDecision?.toUpperCase() === 'APPROVED'
|
|
2105
|
+
? 'approved'
|
|
2106
|
+
: pr.reviewDecision?.toUpperCase() === 'CHANGES_REQUESTED'
|
|
2107
|
+
? 'changes-requested'
|
|
2108
|
+
: pr.reviewDecision?.toUpperCase() === 'REVIEW_REQUIRED'
|
|
2109
|
+
? 'review-required'
|
|
2110
|
+
: 'unknown';
|
|
2111
|
+
const checks = (pr.statusCheckRollup ?? []).map((check) => ({
|
|
2112
|
+
name: check.name,
|
|
2113
|
+
state: mergeCheckState(check),
|
|
2114
|
+
required: requirements.readable ? requirements.requiredChecks.includes(check.name) : null,
|
|
2115
|
+
...(check.detailsUrl?.startsWith('https://') || check.detailsUrl?.startsWith('http://')
|
|
2116
|
+
? { url: check.detailsUrl }
|
|
2117
|
+
: {}),
|
|
2118
|
+
}));
|
|
2119
|
+
const methods = [];
|
|
2120
|
+
if (policy.allow_squash_merge)
|
|
2121
|
+
methods.push('squash');
|
|
2122
|
+
if (policy.allow_merge_commit)
|
|
2123
|
+
methods.push('merge');
|
|
2124
|
+
if (policy.allow_rebase_merge)
|
|
2125
|
+
methods.push('rebase');
|
|
2126
|
+
const defaultMethod = policy.squash_merge_commit_title && methods.includes('squash')
|
|
2127
|
+
? 'squash'
|
|
2128
|
+
: policy.merge_commit_title && methods.includes('merge')
|
|
2129
|
+
? 'merge'
|
|
2130
|
+
: methods[0] ?? null;
|
|
2131
|
+
const blockers = [];
|
|
2132
|
+
let eligibility = 'ready';
|
|
2133
|
+
if (state !== 'open') {
|
|
2134
|
+
eligibility = 'terminal';
|
|
2135
|
+
blockers.push({ code: 'terminal', message: state === 'merged' ? 'This pull request is merged.' : 'This pull request is closed.' });
|
|
2136
|
+
}
|
|
2137
|
+
else if (pr.isDraft) {
|
|
2138
|
+
eligibility = 'blocked';
|
|
2139
|
+
blockers.push({ code: 'draft', message: 'Mark the pull request ready for review before merging.' });
|
|
2140
|
+
}
|
|
2141
|
+
else if (mergeable === 'conflicting') {
|
|
2142
|
+
eligibility = 'blocked';
|
|
2143
|
+
blockers.push({ code: 'conflicts', message: 'Conflicts must be resolved before merging.' });
|
|
2144
|
+
}
|
|
2145
|
+
else if (checks.some((check) => check.state === 'failing')) {
|
|
2146
|
+
eligibility = 'blocked';
|
|
2147
|
+
blockers.push({ code: 'checks-failing', message: 'One or more checks are failing.' });
|
|
2148
|
+
}
|
|
2149
|
+
else if (reviewDecision === 'changes-requested' || reviewDecision === 'review-required') {
|
|
2150
|
+
eligibility = 'blocked';
|
|
2151
|
+
blockers.push({ code: 'reviews', message: reviewDecision === 'changes-requested' ? 'Changes were requested.' : 'A required review is missing.' });
|
|
2152
|
+
}
|
|
2153
|
+
else if (reviewDecision === 'unknown' || !requirements.readable) {
|
|
2154
|
+
eligibility = 'unknown';
|
|
2155
|
+
blockers.push({
|
|
2156
|
+
code: 'rules-unknown',
|
|
2157
|
+
message: 'GitHub could not confirm review and branch-protection requirements.',
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
else if (checks.some((check) => check.state === 'pending') || pr.mergeStateStatus?.toUpperCase() === 'UNSTABLE') {
|
|
2161
|
+
eligibility = 'pending';
|
|
2162
|
+
blockers.push({ code: 'pending', message: 'Checks or GitHub mergeability are still pending.' });
|
|
2163
|
+
}
|
|
2164
|
+
else if (mergeable !== 'mergeable' ||
|
|
2165
|
+
!['CLEAN', 'HAS_HOOKS'].includes(pr.mergeStateStatus?.toUpperCase() ?? '') ||
|
|
2166
|
+
methods.length === 0) {
|
|
2167
|
+
eligibility = 'unknown';
|
|
2168
|
+
blockers.push({ code: 'unknown', message: 'GitHub could not confirm every merge requirement.' });
|
|
2169
|
+
}
|
|
2170
|
+
const canMerge = eligibility === 'ready';
|
|
2171
|
+
const canOverride = !canMerge &&
|
|
2172
|
+
state === 'open' &&
|
|
2173
|
+
!pr.isDraft &&
|
|
2174
|
+
mergeable !== 'conflicting' &&
|
|
2175
|
+
methods.length > 0;
|
|
2176
|
+
return {
|
|
2177
|
+
number: pr.number,
|
|
2178
|
+
title: pr.title,
|
|
2179
|
+
url: pr.url,
|
|
2180
|
+
state,
|
|
2181
|
+
isDraft: pr.isDraft,
|
|
2182
|
+
headRef: pr.headRefName,
|
|
2183
|
+
baseRef: pr.baseRefName,
|
|
2184
|
+
headSha: pr.headRefOid,
|
|
2185
|
+
mergeable,
|
|
2186
|
+
reviewDecision,
|
|
2187
|
+
checks,
|
|
2188
|
+
methods,
|
|
2189
|
+
defaultMethod,
|
|
2190
|
+
eligibility,
|
|
2191
|
+
blockers,
|
|
2192
|
+
canMerge,
|
|
2193
|
+
canOverride,
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
async function fetchPrMergeState(repoRoot, repoRef, number, refresh = false) {
|
|
2197
|
+
if (process.env.CEZ_DRY_RUN === '1') {
|
|
2198
|
+
return {
|
|
2199
|
+
available: true,
|
|
2200
|
+
mergeState: normalizeMergeState({
|
|
2201
|
+
number,
|
|
2202
|
+
title: 'Dry-run pull request',
|
|
2203
|
+
url: `https://github.com/mock/repo/pull/${number}`,
|
|
2204
|
+
state: 'OPEN',
|
|
2205
|
+
isDraft: false,
|
|
2206
|
+
headRefName: 'feat/dry-run',
|
|
2207
|
+
baseRefName: 'main',
|
|
2208
|
+
headRefOid: '0123456789abcdef0123456789abcdef01234567',
|
|
2209
|
+
mergeable: 'MERGEABLE',
|
|
2210
|
+
mergeStateStatus: 'CLEAN',
|
|
2211
|
+
reviewDecision: 'APPROVED',
|
|
2212
|
+
statusCheckRollup: [{ name: 'test', conclusion: 'SUCCESS', detailsUrl: 'https://github.com/mock/repo/actions' }],
|
|
2213
|
+
}, { allow_merge_commit: true, allow_squash_merge: true, allow_rebase_merge: true }, { readable: true, requiredChecks: ['test'] }),
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
if (!repoRef)
|
|
2217
|
+
return { available: false, reason: 'GitHub remote could not be resolved' };
|
|
2218
|
+
const key = `${repoRoot}:${number}`;
|
|
2219
|
+
const hit = mergeStateCache.get(key);
|
|
2220
|
+
if (!refresh && hit && Date.now() - hit.at < MERGE_CACHE_MS)
|
|
2221
|
+
return hit.value;
|
|
2222
|
+
try {
|
|
2223
|
+
const prOut = await gh(repoRoot, [
|
|
2224
|
+
'pr', 'view', String(number), '--json',
|
|
2225
|
+
'number,title,url,state,isDraft,headRefName,baseRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup',
|
|
2226
|
+
]);
|
|
2227
|
+
const parsedPr = mergePrSchema.parse(JSON.parse(prOut));
|
|
2228
|
+
const [policyOut, requiredChecks] = await Promise.all([
|
|
2229
|
+
gh(repoRoot, ['api', `repos/${repoRef.owner}/${repoRef.repo}`]),
|
|
2230
|
+
gh(repoRoot, [
|
|
2231
|
+
'api',
|
|
2232
|
+
`repos/${repoRef.owner}/${repoRef.repo}/branches/${encodeURIComponent(parsedPr.baseRefName)}/protection/required_status_checks`,
|
|
2233
|
+
'--jq',
|
|
2234
|
+
'[.contexts[]?, .checks[]?.context] | unique',
|
|
2235
|
+
])
|
|
2236
|
+
.then((output) => ({ readable: true, requiredChecks: z.array(z.string()).parse(JSON.parse(output)) }))
|
|
2237
|
+
.catch(() => ({ readable: false, requiredChecks: [] })),
|
|
2238
|
+
]);
|
|
2239
|
+
const value = {
|
|
2240
|
+
available: true,
|
|
2241
|
+
mergeState: normalizeMergeState(parsedPr, JSON.parse(policyOut), requiredChecks),
|
|
2242
|
+
};
|
|
2243
|
+
mergeStateCache.set(key, { at: Date.now(), value });
|
|
2244
|
+
return value;
|
|
2245
|
+
}
|
|
2246
|
+
catch (error) {
|
|
2247
|
+
return { available: false, reason: firstLine(error instanceof Error ? error.message : String(error)) };
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
export function evictGithubProjectCaches(repoRoot) {
|
|
2251
|
+
listCache.delete(repoRoot);
|
|
2252
|
+
mergeStateCache.forEach((_value, key) => {
|
|
2253
|
+
if (key.startsWith(`${repoRoot}:`))
|
|
2254
|
+
mergeStateCache.delete(key);
|
|
2255
|
+
});
|
|
2256
|
+
commentsCache.forEach((_value, key) => {
|
|
2257
|
+
if (key.startsWith(`${repoRoot}:`))
|
|
2258
|
+
commentsCache.delete(key);
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
async function mergePullRequest(repoRoot, repoRef, number, input) {
|
|
2262
|
+
const key = `${repoRoot}:${number}`;
|
|
2263
|
+
if (mergeInflight.has(key))
|
|
2264
|
+
return { merged: false, status: 409, error: 'A merge is already in progress.', code: 'concurrent' };
|
|
2265
|
+
mergeInflight.add(key);
|
|
2266
|
+
try {
|
|
2267
|
+
const fresh = await fetchPrMergeState(repoRoot, repoRef, number, true);
|
|
2268
|
+
if (!fresh.available)
|
|
2269
|
+
return { merged: false, status: 502, error: fresh.reason };
|
|
2270
|
+
const current = fresh.mergeState;
|
|
2271
|
+
if (current.headSha !== input.expectedHeadSha) {
|
|
2272
|
+
return { merged: false, status: 409, error: 'The pull request head changed. Review the new commits before merging.', code: 'stale-head', current };
|
|
2273
|
+
}
|
|
2274
|
+
if (!current.methods.includes(input.method)) {
|
|
2275
|
+
return { merged: false, status: 409, error: 'That merge method is no longer enabled.', code: 'disabled-method', current };
|
|
2276
|
+
}
|
|
2277
|
+
if (!mergePreflightAllowed(current, input.overrideRules)) {
|
|
2278
|
+
return { merged: false, status: 409, error: current.blockers[0]?.message ?? 'The pull request is not eligible to merge.', code: current.eligibility, current };
|
|
2279
|
+
}
|
|
2280
|
+
if (process.env.CEZ_DRY_RUN === '1') {
|
|
2281
|
+
evictGithubProjectCaches(repoRoot);
|
|
2282
|
+
return { merged: true, number, url: current.url, method: input.method, mergeCommitSha: 'abcdef0123456789abcdef0123456789abcdef01' };
|
|
2283
|
+
}
|
|
2284
|
+
if (!repoRef)
|
|
2285
|
+
return { merged: false, status: 404, error: 'GitHub repository not found.' };
|
|
2286
|
+
const out = await gh(repoRoot, [
|
|
2287
|
+
'api', '--method', 'PUT', `repos/${repoRef.owner}/${repoRef.repo}/pulls/${number}/merge`,
|
|
2288
|
+
'-f', `merge_method=${input.method}`, '-f', `sha=${input.expectedHeadSha}`,
|
|
2289
|
+
]);
|
|
2290
|
+
const result = ghMergeResultSchema.parse(JSON.parse(out));
|
|
2291
|
+
if (!result.merged)
|
|
2292
|
+
return { merged: false, status: 409, error: result.message ?? 'GitHub refused the merge.', code: 'github-blocked', current };
|
|
2293
|
+
evictGithubProjectCaches(repoRoot);
|
|
2294
|
+
return { merged: true, number, url: current.url, method: input.method, ...(result.sha ? { mergeCommitSha: result.sha } : {}) };
|
|
2295
|
+
}
|
|
2296
|
+
catch (error) {
|
|
2297
|
+
const message = firstLine(error instanceof Error ? error.message : String(error));
|
|
2298
|
+
const status = /403|permission|forbidden/i.test(message) ? 403 : /404|not found/i.test(message) ? 404 : 502;
|
|
2299
|
+
return { merged: false, status, error: status === 403 ? 'GitHub permission denied.' : status === 404 ? 'Pull request or repository not found.' : 'GitHub could not complete the merge.' };
|
|
2300
|
+
}
|
|
2301
|
+
finally {
|
|
2302
|
+
mergeInflight.delete(key);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
export function mergePreflightAllowed(current, overrideRules = false) {
|
|
2306
|
+
return current.canMerge || (overrideRules && current.canOverride);
|
|
2307
|
+
}
|
|
2308
|
+
const GH_PR_STATES = {
|
|
2309
|
+
MERGED: 'merged',
|
|
2310
|
+
CLOSED: 'closed',
|
|
2311
|
+
};
|
|
2312
|
+
export function createGithubDriver(repoRoot, repoRef) {
|
|
2313
|
+
return {
|
|
2314
|
+
kind: 'github',
|
|
2315
|
+
detect: () => detectGithub(repoRoot),
|
|
2316
|
+
detectCached: () => detectGithubCached(repoRoot),
|
|
2317
|
+
listIssues: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).issues,
|
|
2318
|
+
listPRs: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).prs,
|
|
2319
|
+
prDiff: (number, opts) => fetchGithubPrDiff(repoRoot, number, opts?.refresh),
|
|
2320
|
+
createPR: (input) => createDraftPr(input),
|
|
2321
|
+
// Null covers everything from "no PR yet" to "gh missing" — the callers
|
|
2322
|
+
// (Create PR → View PR flip) treat all of it as "nothing to link".
|
|
2323
|
+
prStatus: async (branch) => {
|
|
2324
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
2325
|
+
return null;
|
|
2326
|
+
try {
|
|
2327
|
+
const out = await gh(repoRoot, ['pr', 'view', branch, '--json', 'number,url,state,isDraft,statusCheckRollup']);
|
|
2328
|
+
const pr = ghPrViewSchema.parse(JSON.parse(out));
|
|
2329
|
+
return {
|
|
2330
|
+
number: pr.number,
|
|
2331
|
+
url: pr.url,
|
|
2332
|
+
state: GH_PR_STATES[pr.state.toUpperCase()] ?? 'open',
|
|
2333
|
+
isDraft: pr.isDraft,
|
|
2334
|
+
checks: rollupToChecks(pr.statusCheckRollup) ?? null,
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
catch {
|
|
2338
|
+
return null;
|
|
2339
|
+
}
|
|
2340
|
+
},
|
|
2341
|
+
prMergeState: (number, opts) => fetchPrMergeState(repoRoot, repoRef, number, opts?.refresh),
|
|
2342
|
+
mergePR: (number, input) => mergePullRequest(repoRoot, repoRef, number, input),
|
|
2343
|
+
viewUrl: (kind, ref) => {
|
|
2344
|
+
if (!repoRef)
|
|
2345
|
+
return null;
|
|
2346
|
+
const base = `https://github.com/${repoRef.owner}/${repoRef.repo}`;
|
|
2347
|
+
// Branch names may contain '/' — encode per segment, keep the slashes.
|
|
2348
|
+
const path = String(ref).split('/').map(encodeURIComponent).join('/');
|
|
2349
|
+
switch (kind) {
|
|
2350
|
+
case 'repo':
|
|
2351
|
+
return base;
|
|
2352
|
+
case 'issue':
|
|
2353
|
+
return `${base}/issues/${path}`;
|
|
2354
|
+
case 'pr':
|
|
2355
|
+
return `${base}/pull/${path}`;
|
|
2356
|
+
case 'branch':
|
|
2357
|
+
return `${base}/tree/${path}`;
|
|
2358
|
+
case 'commit':
|
|
2359
|
+
return `${base}/commit/${path}`;
|
|
2360
|
+
}
|
|
2361
|
+
},
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
//# sourceMappingURL=github.js.map
|