@toolu/opencode 6.8.0
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/generated/GENERATED-NOTES.md +16 -0
- package/generated/agents/toolu--architect.md +52 -0
- package/generated/agents/toolu--deep-explore.md +65 -0
- package/generated/agents/toolu--implementer.md +50 -0
- package/generated/agents/toolu--quick-task.md +54 -0
- package/generated/agents/toolu--research-agent.md +101 -0
- package/generated/commands/toolu--commit.md +8 -0
- package/generated/commands/toolu--review-and-commit.md +8 -0
- package/generated/opencode.toolu.json +116 -0
- package/generated/skills/toolu--brainstorm/SKILL.md +57 -0
- package/generated/skills/toolu--brainstorm/references/design-questions.md +36 -0
- package/generated/skills/toolu--commit--skill/SKILL.md +11 -0
- package/generated/skills/toolu--debug/SKILL.md +54 -0
- package/generated/skills/toolu--deep-research/SKILL.md +51 -0
- package/generated/skills/toolu--execution/SKILL.md +77 -0
- package/generated/skills/toolu--orchestrator/SKILL.md +145 -0
- package/generated/skills/toolu--orchestrator/references/delegation-cost.md +104 -0
- package/generated/skills/toolu--orchestrator/references/model-routing.md +112 -0
- package/generated/skills/toolu--plan/SKILL.md +56 -0
- package/generated/skills/toolu--plan/references/ledger.md +18 -0
- package/generated/skills/toolu--plan-review/SKILL.md +48 -0
- package/generated/skills/toolu--review-and-commit--skill/SKILL.md +10 -0
- package/generated/skills/toolu--setup/SKILL.md +24 -0
- package/generated/skills/toolu--setup/scripts/setup.sh +206 -0
- package/generated/skills/toolu--spec/SKILL.md +67 -0
- package/generated/skills/toolu--spec-review/SKILL.md +50 -0
- package/generated/skills/toolu--test/SKILL.md +49 -0
- package/package.json +51 -0
- package/plugins/agent-browser/.claude-plugin/plugin.json +18 -0
- package/plugins/agent-browser/.codex-plugin/plugin.json +7 -0
- package/plugins/agent-browser/README.md +18 -0
- package/plugins/agent-browser/hooks/hooks.json +15 -0
- package/plugins/agent-browser/hooks/session-start.sh +42 -0
- package/plugins/agent-browser/skills/agent-browser/SKILL.md +77 -0
- package/plugins/agent-browser/skills/agent-browser/scripts/agent-browser.sh +80 -0
- package/plugins/ast-grep/.claude-plugin/plugin.json +18 -0
- package/plugins/ast-grep/.codex-plugin/plugin.json +7 -0
- package/plugins/ast-grep/README.md +21 -0
- package/plugins/ast-grep/hooks/hooks.json +15 -0
- package/plugins/ast-grep/hooks/post-tools.d/byte-savings.sh +67 -0
- package/plugins/ast-grep/hooks/pre-tools.d/search-nudge.sh +170 -0
- package/plugins/ast-grep/hooks/register.sh +68 -0
- package/plugins/ast-grep/scripts/byte-savings-report.sh +28 -0
- package/plugins/ast-grep/skills/ast-grep/SKILL.md +127 -0
- package/plugins/ast-grep/skills/ast-grep/references/ast-grep-advanced.md +44 -0
- package/plugins/ast-grep/skills/ast-grep/scripts/ast-grep.sh +103 -0
- package/plugins/context7/.claude-plugin/plugin.json +18 -0
- package/plugins/context7/.codex-plugin/plugin.json +7 -0
- package/plugins/context7/README.md +19 -0
- package/plugins/context7/hooks/hooks.json +15 -0
- package/plugins/context7/hooks/session-start.sh +42 -0
- package/plugins/context7/skills/context7/SKILL.md +64 -0
- package/plugins/context7/skills/context7/scripts/search.sh +151 -0
- package/plugins/exa-search/.claude-plugin/plugin.json +18 -0
- package/plugins/exa-search/.codex-plugin/plugin.json +7 -0
- package/plugins/exa-search/README.md +19 -0
- package/plugins/exa-search/hooks/hooks.json +15 -0
- package/plugins/exa-search/hooks/session-start.sh +34 -0
- package/plugins/exa-search/skills/exa-search/SKILL.md +91 -0
- package/plugins/exa-search/skills/exa-search/scripts/search.sh +221 -0
- package/plugins/jev/.claude-plugin/plugin.json +19 -0
- package/plugins/jev/.codex-plugin/plugin.json +7 -0
- package/plugins/jev/README.md +61 -0
- package/plugins/jev/hooks/hooks.json +25 -0
- package/plugins/jev/hooks/lib/common.sh +49 -0
- package/plugins/jev/hooks/session-start.sh +45 -0
- package/plugins/jev/hooks/user-prompt-submit.sh +41 -0
- package/plugins/jev/skills/jev/SKILL.md +81 -0
- package/plugins/jev/skills/jev/evals/README.md +98 -0
- package/plugins/jev/skills/jev/references/problem-solving.md +198 -0
- package/plugins/jev/skills/jev/scripts/jev.sh +405 -0
- package/plugins/jira/.claude-plugin/plugin.json +18 -0
- package/plugins/jira/.codex-plugin/plugin.json +7 -0
- package/plugins/jira/README.md +42 -0
- package/plugins/jira/hooks/hooks.json +15 -0
- package/plugins/jira/hooks/session-start.sh +34 -0
- package/plugins/jira/skills/jira/SKILL.md +123 -0
- package/plugins/jira/skills/jira/scripts/jira.sh +70 -0
- package/plugins/jira/skills/jira/scripts/lib/adf.sh +17 -0
- package/plugins/jira/skills/jira/scripts/lib/attachment.sh +94 -0
- package/plugins/jira/skills/jira/scripts/lib/board.sh +58 -0
- package/plugins/jira/skills/jira/scripts/lib/http.sh +184 -0
- package/plugins/jira/skills/jira/scripts/lib/issue.sh +159 -0
- package/plugins/jira/skills/jira/scripts/lib/paginate.sh +45 -0
- package/plugins/jira/skills/jira/scripts/lib/plan-parse.sh +70 -0
- package/plugins/jira/skills/jira/scripts/lib/plan-run.sh +115 -0
- package/plugins/jira/skills/jira/scripts/lib/plan-store.sh +168 -0
- package/plugins/jira/skills/jira/scripts/lib/plan.sh +108 -0
- package/plugins/jira/skills/jira/scripts/lib/project.sh +42 -0
- package/plugins/jira/skills/jira/scripts/lib/raw.sh +15 -0
- package/plugins/jira/skills/jira/scripts/lib/search.sh +41 -0
- package/plugins/jira/skills/jira/scripts/lib/sprint.sh +88 -0
- package/plugins/jira/skills/jira/scripts/lib/user.sh +45 -0
- package/plugins/jira/skills/jira/scripts/lib/worklog.sh +56 -0
- package/plugins/pr-babysit/.claude-plugin/plugin.json +25 -0
- package/plugins/pr-babysit/.codex-plugin/plugin.json +7 -0
- package/plugins/pr-babysit/README.md +38 -0
- package/plugins/pr-babysit/commands/babysit.md +13 -0
- package/plugins/pr-babysit/hooks/check-toolu.sh +20 -0
- package/plugins/pr-babysit/hooks/hooks.json +15 -0
- package/plugins/pr-babysit/scripts/babysit-tick.sh +133 -0
- package/plugins/pr-babysit/scripts/collect-pr.sh +224 -0
- package/plugins/pr-babysit/scripts/lib/common.sh +146 -0
- package/plugins/pr-babysit/scripts/lib/gh.sh +150 -0
- package/plugins/pr-babysit/scripts/lib/lock.sh +84 -0
- package/plugins/pr-babysit/scripts/lib/normalize.sh +98 -0
- package/plugins/pr-babysit/scripts/lib/state.sh +37 -0
- package/plugins/pr-babysit/scripts/parse-verdict.sh +171 -0
- package/plugins/pr-babysit/scripts/record.sh +69 -0
- package/plugins/pr-babysit/scripts/reduce-state.sh +245 -0
- package/plugins/pr-babysit/scripts/reply-thread.sh +90 -0
- package/plugins/pr-babysit/scripts/resolve-thread.sh +71 -0
- package/plugins/pr-babysit/skills/babysit/SKILL.md +31 -0
- package/plugins/pr-babysit/skills/babysit/references/helper.md +209 -0
- package/plugins/pr-babysit/workflows/babysit.md +722 -0
- package/plugins/python-quality/.claude-plugin/plugin.json +24 -0
- package/plugins/python-quality/.codex-plugin/plugin.json +6 -0
- package/plugins/python-quality/README.md +23 -0
- package/plugins/python-quality/hooks/check-toolu.sh +20 -0
- package/plugins/python-quality/hooks/concerns/00-preamble.sh +85 -0
- package/plugins/python-quality/hooks/concerns/10-size-file.sh +6 -0
- package/plugins/python-quality/hooks/concerns/20-tests.sh +37 -0
- package/plugins/python-quality/hooks/concerns/30-suppression.sh +33 -0
- package/plugins/python-quality/hooks/concerns/50-size-fn.sh +49 -0
- package/plugins/python-quality/hooks/concerns/70-no-mocks.sh +76 -0
- package/plugins/python-quality/hooks/concerns/90-docs.sh +52 -0
- package/plugins/python-quality/hooks/concerns/99-finalize.sh +36 -0
- package/plugins/python-quality/hooks/hooks.json +19 -0
- package/plugins/python-quality/hooks/register.sh +65 -0
- package/plugins/rust-quality/.claude-plugin/plugin.json +24 -0
- package/plugins/rust-quality/.codex-plugin/plugin.json +6 -0
- package/plugins/rust-quality/README.md +24 -0
- package/plugins/rust-quality/hooks/check-toolu.sh +20 -0
- package/plugins/rust-quality/hooks/concerns/00-preamble.sh +93 -0
- package/plugins/rust-quality/hooks/concerns/10-size-file.sh +10 -0
- package/plugins/rust-quality/hooks/concerns/20-tests.sh +84 -0
- package/plugins/rust-quality/hooks/concerns/30-suppression.sh +26 -0
- package/plugins/rust-quality/hooks/concerns/40-unsafe.sh +39 -0
- package/plugins/rust-quality/hooks/concerns/50-size-fn.sh +38 -0
- package/plugins/rust-quality/hooks/concerns/55-size-impl.sh +28 -0
- package/plugins/rust-quality/hooks/concerns/60-error-handling.sh +149 -0
- package/plugins/rust-quality/hooks/concerns/70-no-mocks.sh +139 -0
- package/plugins/rust-quality/hooks/concerns/90-docs.sh +32 -0
- package/plugins/rust-quality/hooks/concerns/99-finalize.sh +36 -0
- package/plugins/rust-quality/hooks/hooks.json +19 -0
- package/plugins/rust-quality/hooks/register.sh +65 -0
- package/plugins/statusline/.claude-plugin/plugin.json +19 -0
- package/plugins/statusline/.codex-plugin/plugin.json +7 -0
- package/plugins/statusline/README.md +108 -0
- package/plugins/statusline/commands/setup.md +30 -0
- package/plugins/statusline/hooks/hooks.json +15 -0
- package/plugins/statusline/hooks/session-start.sh +45 -0
- package/plugins/statusline/scripts/collect-status.sh +147 -0
- package/plugins/statusline/scripts/setup.sh +93 -0
- package/plugins/statusline/scripts/status.sh +55 -0
- package/plugins/statusline/skills/status/SKILL.md +13 -0
- package/plugins/statusline/statusline.sh +177 -0
- package/plugins/toolu/.claude-plugin/plugin.json +19 -0
- package/plugins/toolu/.codex-plugin/plugin.json +7 -0
- package/plugins/toolu/README.md +35 -0
- package/plugins/toolu/agents/.gitkeep +0 -0
- package/plugins/toolu/agents/architect.md +52 -0
- package/plugins/toolu/agents/deep-explore.md +65 -0
- package/plugins/toolu/agents/implementer.md +50 -0
- package/plugins/toolu/agents/quick-task.md +54 -0
- package/plugins/toolu/agents/research-agent.md +107 -0
- package/plugins/toolu/assets/agents/architect.toml +9 -0
- package/plugins/toolu/assets/agents/deep-explore.toml +9 -0
- package/plugins/toolu/assets/agents/implementer.toml +9 -0
- package/plugins/toolu/assets/agents/quick-task.toml +9 -0
- package/plugins/toolu/assets/agents/research-agent.toml +9 -0
- package/plugins/toolu/commands/.gitkeep +0 -0
- package/plugins/toolu/commands/commit.md +5 -0
- package/plugins/toolu/commands/review-and-commit.md +5 -0
- package/plugins/toolu/hooks/docs/gates.md +157 -0
- package/plugins/toolu/hooks/docs/model-routing.md +14 -0
- package/plugins/toolu/hooks/docs/post-compaction.md +3 -0
- package/plugins/toolu/hooks/docs/push-review.md +123 -0
- package/plugins/toolu/hooks/docs/session-start-python.md +3 -0
- package/plugins/toolu/hooks/docs/session-start-rust.md +4 -0
- package/plugins/toolu/hooks/docs/session-start-ts.md +3 -0
- package/plugins/toolu/hooks/docs/session-start.md +16 -0
- package/plugins/toolu/hooks/hooks.json +76 -0
- package/plugins/toolu/hooks/lib/config.sh +355 -0
- package/plugins/toolu/hooks/lib/detect.sh +929 -0
- package/plugins/toolu/hooks/lib/diff-sha.sh +32 -0
- package/plugins/toolu/hooks/lib/dispatch.sh +365 -0
- package/plugins/toolu/hooks/lib/docs-sync-config.sh +81 -0
- package/plugins/toolu/hooks/lib/edit-records.sh +157 -0
- package/plugins/toolu/hooks/lib/gate-file.sh +201 -0
- package/plugins/toolu/hooks/lib/gate-mode.sh +244 -0
- package/plugins/toolu/hooks/lib/host.sh +205 -0
- package/plugins/toolu/hooks/lib/permissions.sh +125 -0
- package/plugins/toolu/hooks/lib/plan-ledger-parse.sh +219 -0
- package/plugins/toolu/hooks/lib/plan-ledger-preflight.sh +137 -0
- package/plugins/toolu/hooks/lib/plan-ledger.sh +749 -0
- package/plugins/toolu/hooks/lib/push-waiver.sh +116 -0
- package/plugins/toolu/hooks/lib/quality-config.sh +184 -0
- package/plugins/toolu/hooks/lib/registry.sh +72 -0
- package/plugins/toolu/hooks/lib/state-sweeper.sh +194 -0
- package/plugins/toolu/hooks/lib/telemetry.sh +94 -0
- package/plugins/toolu/hooks/lib/verdict.sh +477 -0
- package/plugins/toolu/hooks/post-tools/mod.sh +42 -0
- package/plugins/toolu/hooks/post-tools/modules/gate-status.sh +140 -0
- package/plugins/toolu/hooks/post-tools/modules/push-waiver.sh +97 -0
- package/plugins/toolu/hooks/pre-compact.sh +18 -0
- package/plugins/toolu/hooks/pre-tools/agent-tier.sh +111 -0
- package/plugins/toolu/hooks/pre-tools/mod.sh +37 -0
- package/plugins/toolu/hooks/pre-tools/modules/bash-commands.sh +187 -0
- package/plugins/toolu/hooks/pre-tools/modules/code-edit-rules.sh +78 -0
- package/plugins/toolu/hooks/pre-tools/modules/commit-gate.sh +70 -0
- package/plugins/toolu/hooks/pre-tools/modules/docs-sync.sh +137 -0
- package/plugins/toolu/hooks/pre-tools/modules/mcp-blocker.sh +151 -0
- package/plugins/toolu/hooks/pre-tools/modules/plan-ledger.sh +201 -0
- package/plugins/toolu/hooks/pre-tools/modules/protected-files.sh +177 -0
- package/plugins/toolu/hooks/pre-tools/modules/push-review.sh +273 -0
- package/plugins/toolu/hooks/pre-tools/modules/quality-gate.sh +87 -0
- package/plugins/toolu/hooks/session-start.sh +379 -0
- package/plugins/toolu/hooks/user-prompt-submit.sh +197 -0
- package/plugins/toolu/scripts/context-budget.sh +111 -0
- package/plugins/toolu/scripts/debug-log.sh +98 -0
- package/plugins/toolu/scripts/debug-stack.sh +108 -0
- package/plugins/toolu/scripts/debug-testfail.sh +97 -0
- package/plugins/toolu/settings/.gitkeep +0 -0
- package/plugins/toolu/settings/README.md +127 -0
- package/plugins/toolu/settings/bash-allowlist.txt +12 -0
- package/plugins/toolu/settings/bash-denylist.txt +25 -0
- package/plugins/toolu/settings/code-edit-rules.json +67 -0
- package/plugins/toolu/settings/commit-prefixes.txt +12 -0
- package/plugins/toolu/settings/mcp-blocklist.txt +8 -0
- package/plugins/toolu/settings/permissions.fragment.json +36 -0
- package/plugins/toolu/settings/protected-files.txt +30 -0
- package/plugins/toolu/settings/rust-unsafe-exemptions.txt +5 -0
- package/plugins/toolu/settings/toolu.config.example.json +130 -0
- package/plugins/toolu/skills/.gitkeep +0 -0
- package/plugins/toolu/skills/brainstorm/SKILL.md +57 -0
- package/plugins/toolu/skills/brainstorm/references/design-questions.md +36 -0
- package/plugins/toolu/skills/commit/SKILL.md +11 -0
- package/plugins/toolu/skills/debug/SKILL.md +54 -0
- package/plugins/toolu/skills/debug/evals/README.md +17 -0
- package/plugins/toolu/skills/debug/evals/cases.json +24 -0
- package/plugins/toolu/skills/debug/evals/results.json +34 -0
- package/plugins/toolu/skills/deep-research/SKILL.md +51 -0
- package/plugins/toolu/skills/execution/SKILL.md +77 -0
- package/plugins/toolu/skills/orchestrator/SKILL.md +145 -0
- package/plugins/toolu/skills/orchestrator/references/delegation-cost.md +104 -0
- package/plugins/toolu/skills/orchestrator/references/model-routing.md +112 -0
- package/plugins/toolu/skills/plan/SKILL.md +56 -0
- package/plugins/toolu/skills/plan/references/ledger.md +18 -0
- package/plugins/toolu/skills/plan-review/SKILL.md +48 -0
- package/plugins/toolu/skills/review-and-commit/SKILL.md +10 -0
- package/plugins/toolu/skills/setup/SKILL.md +24 -0
- package/plugins/toolu/skills/setup/scripts/setup.sh +206 -0
- package/plugins/toolu/skills/spec/SKILL.md +67 -0
- package/plugins/toolu/skills/spec-review/SKILL.md +50 -0
- package/plugins/toolu/skills/test/SKILL.md +49 -0
- package/plugins/toolu/workflows/commit.md +21 -0
- package/plugins/toolu/workflows/host-mapping.md +22 -0
- package/plugins/toolu/workflows/review-and-commit.md +54 -0
- package/plugins/toolu/workflows/semantic-judgments.md +35 -0
- package/plugins/toolu-review/.claude-plugin/plugin.json +18 -0
- package/plugins/toolu-review/.codex-plugin/plugin.json +7 -0
- package/plugins/toolu-review/README.md +17 -0
- package/plugins/toolu-review/hooks/hooks.json +15 -0
- package/plugins/toolu-review/hooks/session-start.sh +34 -0
- package/plugins/toolu-review/skills/review/SKILL.md +90 -0
- package/plugins/toolu-review/skills/review/scripts/write-state.sh +189 -0
- package/plugins/ts-quality/.claude-plugin/plugin.json +24 -0
- package/plugins/ts-quality/.codex-plugin/plugin.json +6 -0
- package/plugins/ts-quality/README.md +24 -0
- package/plugins/ts-quality/hooks/check-toolu.sh +20 -0
- package/plugins/ts-quality/hooks/concerns/00-preamble.sh +123 -0
- package/plugins/ts-quality/hooks/concerns/10-imports.sh +33 -0
- package/plugins/ts-quality/hooks/concerns/15-type-as.sh +9 -0
- package/plugins/ts-quality/hooks/concerns/20-tests.sh +26 -0
- package/plugins/ts-quality/hooks/concerns/25-size-file.sh +40 -0
- package/plugins/ts-quality/hooks/concerns/30-size-fn.sh +53 -0
- package/plugins/ts-quality/hooks/concerns/35-react-hooks.sh +7 -0
- package/plugins/ts-quality/hooks/concerns/40-factory.sh +5 -0
- package/plugins/ts-quality/hooks/concerns/45-typeguard.sh +6 -0
- package/plugins/ts-quality/hooks/concerns/50-type-dup.sh +16 -0
- package/plugins/ts-quality/hooks/concerns/55-naming.sh +11 -0
- package/plugins/ts-quality/hooks/concerns/60-console.sh +6 -0
- package/plugins/ts-quality/hooks/concerns/65-suppression.sh +12 -0
- package/plugins/ts-quality/hooks/concerns/70-ui-confirm.sh +8 -0
- package/plugins/ts-quality/hooks/concerns/72-ui-radix.sh +12 -0
- package/plugins/ts-quality/hooks/concerns/74-react-props.sh +6 -0
- package/plugins/ts-quality/hooks/concerns/76-toast.sh +10 -0
- package/plugins/ts-quality/hooks/concerns/78-error-ast.sh +242 -0
- package/plugins/ts-quality/hooks/concerns/80-throw-literal.sh +23 -0
- package/plugins/ts-quality/hooks/concerns/85-no-mocks.sh +109 -0
- package/plugins/ts-quality/hooks/concerns/90-duplication.sh +30 -0
- package/plugins/ts-quality/hooks/concerns/92-docs.sh +36 -0
- package/plugins/ts-quality/hooks/concerns/94-handler.sh +15 -0
- package/plugins/ts-quality/hooks/concerns/99-finalize.sh +46 -0
- package/plugins/ts-quality/hooks/hooks.json +19 -0
- package/plugins/ts-quality/hooks/register.sh +65 -0
- package/src/adapter/__tests__/evaluate.test.ts +82 -0
- package/src/adapter/__tests__/permission-map.test.ts +91 -0
- package/src/adapter/evaluate.ts +54 -0
- package/src/adapter/permission-map.ts +177 -0
- package/src/bootstrap/__tests__/bootstrap.test.ts +148 -0
- package/src/bootstrap/entrypoint.ts +18 -0
- package/src/bootstrap/index.ts +5 -0
- package/src/bootstrap/readiness.ts +59 -0
- package/src/bootstrap/result.ts +21 -0
- package/src/bootstrap/runtime.ts +99 -0
- package/src/bootstrap/test-helpers.ts +25 -0
- package/src/host/__tests__/host.test.ts +37 -0
- package/src/host/detect.ts +46 -0
- package/src/host/index.ts +10 -0
- package/src/host/roots.ts +59 -0
- package/src/host/types.ts +14 -0
- package/src/inventory/__tests__/inventory.test.ts +69 -0
- package/src/inventory/index.ts +9 -0
- package/src/inventory/manifest.ts +46 -0
- package/src/inventory/scan.ts +66 -0
- package/src/inventory/selection.ts +92 -0
- package/src/inventory/types.ts +22 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +7 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/table.ts +42 -0
- package/src/plugin/__tests__/plugin-stub.test.ts +6 -0
- package/src/plugin/plugin-stub.ts +7 -0
- package/src/plugin/toolu.ts +142 -0
- package/src/preflight/__tests__/preflight.test.ts +20 -0
- package/src/preflight/check.ts +75 -0
- package/src/preflight/index.ts +2 -0
- package/src/select/__tests__/select.test.ts +52 -0
- package/src/select/index.ts +2 -0
- package/src/select/resolve.ts +87 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# collect-pr.sh — the read side of the pr-babysit helper.
|
|
3
|
+
#
|
|
4
|
+
# Fetches everything one babysit tick needs from GitHub and writes ONE
|
|
5
|
+
# versioned snapshot. No decisions are made here (see reduce-state.sh); the
|
|
6
|
+
# only judgment is "which comment is the CI reviewer's verdict", delegated to
|
|
7
|
+
# parse-verdict.sh.
|
|
8
|
+
#
|
|
9
|
+
# Usage: collect-pr.sh --repo <owner/repo> --pr <n> --out <path>
|
|
10
|
+
# [--page-size N] [--timeout SECONDS]
|
|
11
|
+
#
|
|
12
|
+
# Behavior (spec: docs/toolu/specs/2026-09-19-pr-babysit-helper-design.md):
|
|
13
|
+
# - four independent reads run concurrently as background jobs (pr view,
|
|
14
|
+
# review threads, issue comments, reviews), each through lib/gh.sh's
|
|
15
|
+
# timeout + bounded retry; any failure aborts the whole collection with a
|
|
16
|
+
# structured error and NO snapshot;
|
|
17
|
+
# - reviewThreads paginate via `gh api graphql --paginate --slurp`; a
|
|
18
|
+
# thread whose comments overflow one page is completed with a cursor loop
|
|
19
|
+
# on node(id:); REST comments/reviews paginate via --paginate --slurp;
|
|
20
|
+
# - the PR head is read before and after the fan-out; if it moved the whole
|
|
21
|
+
# collection runs again once, then fails with head_moved;
|
|
22
|
+
# - the bot comment is the LAST CI-reviewer issue comment that
|
|
23
|
+
# parse-verdict.sh recognises as a review, else the last CI-reviewer
|
|
24
|
+
# comment (verdict state unknown), else null (state absent).
|
|
25
|
+
# Exit: 0 snapshot written · 2 usage · 3 structured error (stdout JSON).
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
PB_SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
|
|
29
|
+
# shellcheck source=lib/common.sh
|
|
30
|
+
. "$PB_SCRIPT_DIR/lib/common.sh"
|
|
31
|
+
# shellcheck source=lib/gh.sh
|
|
32
|
+
. "$PB_SCRIPT_DIR/lib/gh.sh"
|
|
33
|
+
# shellcheck source=lib/normalize.sh
|
|
34
|
+
. "$PB_SCRIPT_DIR/lib/normalize.sh"
|
|
35
|
+
|
|
36
|
+
repo=""; number=""; out=""; page_size=100
|
|
37
|
+
while [ $# -gt 0 ]; do
|
|
38
|
+
case "$1" in
|
|
39
|
+
--repo) repo="${2:-}"; shift 2 ;;
|
|
40
|
+
--pr) number="${2:-}"; shift 2 ;;
|
|
41
|
+
--out) out="${2:-}"; shift 2 ;;
|
|
42
|
+
--page-size) page_size="${2:-}"; shift 2 ;;
|
|
43
|
+
--timeout) PB_GH_TIMEOUT="${2:-}"; shift 2 ;;
|
|
44
|
+
*) pb_fail usage "collect-pr.sh: unknown argument: $1" ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
[[ "$repo" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || pb_fail usage "collect-pr.sh: --repo <owner/repo> required"
|
|
48
|
+
[[ "$number" =~ ^[0-9]+$ ]] || pb_fail usage "collect-pr.sh: --pr <n> required"
|
|
49
|
+
[ -n "$out" ] || pb_fail usage "collect-pr.sh: --out <path> required"
|
|
50
|
+
[[ "$page_size" =~ ^[0-9]+$ ]] && [ "$page_size" -ge 1 ] || pb_fail usage "collect-pr.sh: --page-size must be a positive integer"
|
|
51
|
+
[[ "$PB_GH_TIMEOUT" =~ ^[0-9]+$ ]] && [ "$PB_GH_TIMEOUT" -ge 1 ] || pb_fail usage "collect-pr.sh: --timeout must be a positive integer"
|
|
52
|
+
|
|
53
|
+
pb_require jq gh
|
|
54
|
+
pb_init
|
|
55
|
+
pb_mktmpdir
|
|
56
|
+
owner="${repo%%/*}"; name="${repo#*/}"
|
|
57
|
+
PARSER="$(pb_plugin_root)/scripts/parse-verdict.sh"
|
|
58
|
+
# _collect_once returns this when the PR head moved between its two reads.
|
|
59
|
+
PB_HEAD_MOVED_RC=10
|
|
60
|
+
[ -f "$PARSER" ] || pb_fail usage "collect-pr.sh: parse-verdict.sh not found at $PARSER"
|
|
61
|
+
|
|
62
|
+
# _read_head -> current headRefOid on stdout, or a structured error document
|
|
63
|
+
# on stdout with a non-zero status. It runs inside `$(...)`, so it must NOT
|
|
64
|
+
# exit: an `exit` there only leaves the subshell and the caller would carry
|
|
65
|
+
# on with the error text as a "head". The caller re-emits via _die_with_doc.
|
|
66
|
+
_read_head() {
|
|
67
|
+
pb_gh "$PB_TMPDIR/head.json" pr view "$number" --repo "$repo" --json headRefOid || {
|
|
68
|
+
pb_error api_error "gh head failed after ${PB_GH_ATTEMPTED:-0} attempt(s): ${PB_GH_LAST_ERR:-rc ${PB_GH_LAST_RC:-?}}" \
|
|
69
|
+
"$(jq -nc --argjson attempts "${PB_GH_ATTEMPTED:-0}" --arg class "${PB_GH_CLASS:-}" --arg lastMessage "${PB_GH_LAST_ERR:-}" \
|
|
70
|
+
'{source:"head", attempts:$attempts, class:$class, lastMessage:$lastMessage}')"
|
|
71
|
+
return 1
|
|
72
|
+
}
|
|
73
|
+
jq -er '.headRefOid' "$PB_TMPDIR/head.json" 2>/dev/null \
|
|
74
|
+
|| { pb_error invalid_json "collect-pr.sh: head read returned no headRefOid" '{"source":"head"}'; return 1; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# _die_with_doc DOC — re-emit a captured error document on the real stdout
|
|
78
|
+
# and exit with the code it maps to (used after a failed head read).
|
|
79
|
+
_die_with_doc() {
|
|
80
|
+
printf '%s\n' "$1"
|
|
81
|
+
exit "$(pb_exit_code "$(jq -r '.errors[0].code // "api_error"' <<<"$1" 2>/dev/null || echo api_error)")"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# _job NAME GH_ARGS... — one background read. Writes NAME.json and NAME.meta
|
|
85
|
+
# (the pb_gh outcome) so the parent can report exactly which read failed.
|
|
86
|
+
_job() {
|
|
87
|
+
local jname="$1"; shift
|
|
88
|
+
local rc=0
|
|
89
|
+
pb_gh "$PB_TMPDIR/$jname.json" "$@" || rc=$?
|
|
90
|
+
if [ "$rc" -eq 0 ] && ! pb_gh_json_ok "$PB_TMPDIR/$jname.json"; then
|
|
91
|
+
rc=1; PB_GH_CLASS=invalid_json
|
|
92
|
+
PB_GH_LAST_ERR="response is not valid JSON or carries GraphQL errors[]"
|
|
93
|
+
fi
|
|
94
|
+
jq -nc --argjson rc "$rc" --argjson attempts "${PB_GH_ATTEMPTED:-0}" \
|
|
95
|
+
--arg class "${PB_GH_CLASS:-}" --arg lastMessage "${PB_GH_LAST_ERR:-}" \
|
|
96
|
+
'{rc:$rc, attempts:$attempts, class:$class, lastMessage:$lastMessage}' >"$PB_TMPDIR/$jname.meta"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# _fan_out — run the four reads concurrently, then fail on the first bad meta.
|
|
100
|
+
_fan_out() {
|
|
101
|
+
local j pids=""
|
|
102
|
+
_job pr pr view "$number" --repo "$repo" --json "$(pb_pr_view_fields)" & pids="$pids $!"
|
|
103
|
+
# Strings go through -f (raw); -F would coerce a numeric-looking owner or
|
|
104
|
+
# repo name into a JSON number and break the GraphQL variable types.
|
|
105
|
+
_job threads api graphql --paginate --slurp \
|
|
106
|
+
-f owner="$owner" -f repo="$name" -F number="$number" -F pageSize="$page_size" \
|
|
107
|
+
-f query="$(pb_gql_threads)" & pids="$pids $!"
|
|
108
|
+
_job comments api --paginate --slurp "repos/$repo/issues/$number/comments?per_page=$page_size" & pids="$pids $!"
|
|
109
|
+
_job reviews api --paginate --slurp "repos/$repo/pulls/$number/reviews?per_page=$page_size" & pids="$pids $!"
|
|
110
|
+
for j in $pids; do wait "$j" || true; done
|
|
111
|
+
for j in pr threads comments reviews; do
|
|
112
|
+
[ -f "$PB_TMPDIR/$j.meta" ] || pb_fail api_error "collect-pr.sh: read '$j' produced no outcome" "{\"source\":\"$j\"}"
|
|
113
|
+
if [ "$(jq -r .rc "$PB_TMPDIR/$j.meta")" != 0 ]; then
|
|
114
|
+
local code=api_error
|
|
115
|
+
[ "$(jq -r .class "$PB_TMPDIR/$j.meta")" = invalid_json ] && code=invalid_json
|
|
116
|
+
pb_fail "$code" "collect-pr.sh: read '$j' failed: $(jq -r .lastMessage "$PB_TMPDIR/$j.meta")" \
|
|
117
|
+
"$(jq -c --arg source "$j" '{source:$source, attempts, class, lastMessage}' "$PB_TMPDIR/$j.meta")"
|
|
118
|
+
fi
|
|
119
|
+
done
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# _complete_thread_comments — for every thread whose comment page overflowed,
|
|
123
|
+
# follow the cursor on node(id:) and splice the remaining comments in.
|
|
124
|
+
_complete_thread_comments() {
|
|
125
|
+
local ids tid extra=0
|
|
126
|
+
ids=$(jq -r '.[] | select(.commentsHasNextPage == true) | .id' "$PB_TMPDIR/threads.norm.json")
|
|
127
|
+
[ -n "$ids" ] || { echo 0 >"$PB_TMPDIR/threadComments.pages"; return 0; }
|
|
128
|
+
for tid in $ids; do
|
|
129
|
+
local cursor
|
|
130
|
+
cursor=$(jq -r --arg id "$tid" '.[] | select(.id == $id) | .commentsEndCursor' "$PB_TMPDIR/threads.norm.json")
|
|
131
|
+
# The first page is already in hand: resume from its endCursor.
|
|
132
|
+
pb_gh "$PB_TMPDIR/tc.json" api graphql --paginate --slurp \
|
|
133
|
+
-f id="$tid" -F pageSize="$page_size" -f endCursor="$cursor" \
|
|
134
|
+
-f query="$(pb_gql_thread_comments)" || pb_gh_fail threadComments
|
|
135
|
+
pb_gh_json_ok "$PB_TMPDIR/tc.json" || pb_fail invalid_json "collect-pr.sh: thread comment page for $tid is not valid JSON" '{"source":"threadComments"}'
|
|
136
|
+
extra=$((extra + $(jq 'length' "$PB_TMPDIR/tc.json")))
|
|
137
|
+
jq "$(pb_jq_thread_comments_from_pages)" "$PB_TMPDIR/tc.json" >"$PB_TMPDIR/tc.norm.json"
|
|
138
|
+
jq --arg id "$tid" --slurpfile more "$PB_TMPDIR/tc.norm.json" \
|
|
139
|
+
'map(if .id == $id then .comments += $more[0] | .commentsHasNextPage = false | .commentsEndCursor = null else . end)' \
|
|
140
|
+
"$PB_TMPDIR/threads.norm.json" >"$PB_TMPDIR/threads.norm.next.json"
|
|
141
|
+
mv "$PB_TMPDIR/threads.norm.next.json" "$PB_TMPDIR/threads.norm.json"
|
|
142
|
+
done
|
|
143
|
+
echo "$extra" >"$PB_TMPDIR/threadComments.pages"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# _select_bot_comment — write bot.json: {comment, verdict}.
|
|
147
|
+
_select_bot_comment() {
|
|
148
|
+
local ids cid body chosen=""
|
|
149
|
+
ids=$(jq -r "$(pb_jq_defs) [.[] | select(.author | is_ci_reviewer)] | reverse | .[].id" "$PB_TMPDIR/comments.norm.json")
|
|
150
|
+
for cid in $ids; do
|
|
151
|
+
body=$(jq -r --argjson id "$cid" '.[] | select(.id == $id) | .body' "$PB_TMPDIR/comments.norm.json")
|
|
152
|
+
printf '%s' "$body" | bash "$PARSER" >"$PB_TMPDIR/verdict.json"
|
|
153
|
+
if jq -e '.is_review_comment == true' "$PB_TMPDIR/verdict.json" >/dev/null; then chosen="$cid"; break; fi
|
|
154
|
+
done
|
|
155
|
+
if [ -z "$chosen" ]; then
|
|
156
|
+
# No recognised review: fall back to the last CI-reviewer comment (state
|
|
157
|
+
# unknown, degraded) or none at all (state absent).
|
|
158
|
+
chosen=$(printf '%s\n' "$ids" | head -n 1)
|
|
159
|
+
if [ -n "$chosen" ]; then
|
|
160
|
+
body=$(jq -r --argjson id "$chosen" '.[] | select(.id == $id) | .body' "$PB_TMPDIR/comments.norm.json")
|
|
161
|
+
printf '%s' "$body" | bash "$PARSER" >"$PB_TMPDIR/verdict.json"
|
|
162
|
+
else
|
|
163
|
+
printf '' | bash "$PARSER" | jq '.state = "absent"' >"$PB_TMPDIR/verdict.json"
|
|
164
|
+
fi
|
|
165
|
+
fi
|
|
166
|
+
if [ -n "$chosen" ]; then
|
|
167
|
+
jq -c --argjson id "$chosen" --slurpfile v "$PB_TMPDIR/verdict.json" \
|
|
168
|
+
'{comment: (.[] | select(.id == $id) | {id, url, createdAt, updatedAt, author}), verdict: $v[0]}' \
|
|
169
|
+
"$PB_TMPDIR/comments.norm.json" >"$PB_TMPDIR/bot.json"
|
|
170
|
+
else
|
|
171
|
+
jq -nc --slurpfile v "$PB_TMPDIR/verdict.json" '{comment: null, verdict: $v[0]}' >"$PB_TMPDIR/bot.json"
|
|
172
|
+
fi
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_collect_once() {
|
|
176
|
+
local head_before head_after
|
|
177
|
+
head_before=$(_read_head) || _die_with_doc "$head_before"
|
|
178
|
+
_fan_out
|
|
179
|
+
jq "$(pb_jq_threads_from_pages)" "$PB_TMPDIR/threads.json" >"$PB_TMPDIR/threads.norm.json"
|
|
180
|
+
_complete_thread_comments
|
|
181
|
+
jq "$(pb_jq_rest_items) | map($(pb_jq_issue_comment))" "$PB_TMPDIR/comments.json" >"$PB_TMPDIR/comments.norm.json"
|
|
182
|
+
jq "$(pb_jq_rest_items) | map($(pb_jq_review))" "$PB_TMPDIR/reviews.json" >"$PB_TMPDIR/reviews.norm.json"
|
|
183
|
+
_select_bot_comment
|
|
184
|
+
head_after=$(_read_head) || _die_with_doc "$head_after"
|
|
185
|
+
if [ "$head_before" != "$head_after" ]; then
|
|
186
|
+
: >"$PB_TMPDIR/head.moved"
|
|
187
|
+
return "$PB_HEAD_MOVED_RC"
|
|
188
|
+
fi
|
|
189
|
+
printf '%s' "$head_after" >"$PB_TMPDIR/head.sha"
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# A moved head (rc 10) earns exactly one more collection; anything else has
|
|
193
|
+
# already exited through pb_fail inside _collect_once.
|
|
194
|
+
moved=0
|
|
195
|
+
if ! pb_retry_on_rc "$PB_HEAD_MOVED_RC" 2 _collect_once; then
|
|
196
|
+
pb_fail head_moved "collect-pr.sh: PR head moved twice during collection" '{"source":"head"}'
|
|
197
|
+
fi
|
|
198
|
+
[ ! -f "$PB_TMPDIR/head.moved" ] || moved=1
|
|
199
|
+
|
|
200
|
+
_build_snapshot() {
|
|
201
|
+
jq -n \
|
|
202
|
+
--arg collectedAt "$(pb_now)" --arg repo "$repo" --argjson number "$number" \
|
|
203
|
+
--arg head "$(cat "$PB_TMPDIR/head.sha")" --argjson recollected "$moved" \
|
|
204
|
+
--argjson pageSize "$page_size" \
|
|
205
|
+
--slurpfile pr "$PB_TMPDIR/pr.json" \
|
|
206
|
+
--slurpfile threads "$PB_TMPDIR/threads.norm.json" \
|
|
207
|
+
--slurpfile comments "$PB_TMPDIR/comments.norm.json" \
|
|
208
|
+
--slurpfile reviews "$PB_TMPDIR/reviews.norm.json" \
|
|
209
|
+
--slurpfile bot "$PB_TMPDIR/bot.json" \
|
|
210
|
+
--argjson pThreads "$(jq 'length' "$PB_TMPDIR/threads.json")" \
|
|
211
|
+
--argjson pThreadComments "$(cat "$PB_TMPDIR/threadComments.pages")" \
|
|
212
|
+
--argjson pComments "$(jq 'length' "$PB_TMPDIR/comments.json")" \
|
|
213
|
+
--argjson pReviews "$(jq 'length' "$PB_TMPDIR/reviews.json")" \
|
|
214
|
+
'{version: 1, collectedAt: $collectedAt, repo: $repo, number: $number,
|
|
215
|
+
head: {sha: $head, verifiedAfterFanout: true, recollected: ($recollected == 1)},
|
|
216
|
+
pageSize: $pageSize,
|
|
217
|
+
pr: ($pr[0] | {number, title, url, author: (.author.login // null), state, baseRefName, headRefName,
|
|
218
|
+
headRefOid, mergeable, reviewDecision, statusCheckRollup: (.statusCheckRollup // [])}),
|
|
219
|
+
threads: ($threads[0] | map(del(.commentsHasNextPage, .commentsEndCursor))),
|
|
220
|
+
comments: $comments[0], reviews: $reviews[0], bot: $bot[0],
|
|
221
|
+
pages: {threads: $pThreads, threadComments: $pThreadComments, comments: $pComments, reviews: $pReviews}}'
|
|
222
|
+
}
|
|
223
|
+
pb_atomic_write_json "$out" _build_snapshot || pb_fail invalid_json "collect-pr.sh: could not assemble the snapshot" '{"source":"snapshot"}'
|
|
224
|
+
printf '%s\n' "$out"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# common.sh — shared plumbing for the pr-babysit helper scripts.
|
|
3
|
+
#
|
|
4
|
+
# Sourced, never run. Provides: plugin-root resolution from this file's own
|
|
5
|
+
# location (no environment variable names the root), dependency checks, a
|
|
6
|
+
# structured-error emitter with the closed exit-code map, an atomic JSON
|
|
7
|
+
# writer, and the single EXIT handler every entrypoint installs via pb_init.
|
|
8
|
+
#
|
|
9
|
+
# Portability contract (see docs/toolu/specs/2026-09-19-pr-babysit-helper-design.md):
|
|
10
|
+
# bash 3.2 — no mapfile/readarray, no declare -A, no wait -n, no ${var,,}.
|
|
11
|
+
|
|
12
|
+
# Closed error-code → exit-code map. Adding a code is a schema change.
|
|
13
|
+
# usage 2 · gh_unavailable/jq_required/api_error/invalid_json/head_moved/
|
|
14
|
+
# state_malformed/slot_mismatch 3 · duplicate_reply 4 · resolve_unconfirmed 5
|
|
15
|
+
# locked 75 (EX_TEMPFAIL)
|
|
16
|
+
pb_exit_code() {
|
|
17
|
+
case "$1" in
|
|
18
|
+
usage) echo 2 ;;
|
|
19
|
+
duplicate_reply) echo 4 ;;
|
|
20
|
+
resolve_unconfirmed) echo 5 ;;
|
|
21
|
+
locked) echo 75 ;;
|
|
22
|
+
gh_unavailable|jq_required|api_error|invalid_json|head_moved|state_malformed|slot_mismatch) echo 3 ;;
|
|
23
|
+
*) echo 3 ;;
|
|
24
|
+
esac
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# pb_plugin_root -> absolute path of plugins/pr-babysit (this file lives in
|
|
28
|
+
# scripts/lib/). Resolved from BASH_SOURCE so an installed copy under any
|
|
29
|
+
# host cache path works without configuration.
|
|
30
|
+
pb_plugin_root() {
|
|
31
|
+
local here
|
|
32
|
+
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) || return 1
|
|
33
|
+
(cd "$here/../.." && pwd -P)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# pb_require CMD... -> exit through pb_fail when a dependency is missing.
|
|
37
|
+
pb_require() {
|
|
38
|
+
local cmd
|
|
39
|
+
for cmd in "$@"; do
|
|
40
|
+
command -v "$cmd" >/dev/null 2>&1 && continue
|
|
41
|
+
case "$cmd" in
|
|
42
|
+
jq) pb_fail jq_required "jq is required" ;;
|
|
43
|
+
gh) pb_fail gh_unavailable "gh is required" ;;
|
|
44
|
+
*) pb_fail gh_unavailable "$cmd is required" ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# pb_error CODE MESSAGE [EXTRA_JSON] -> one structured error document on stdout.
|
|
50
|
+
# EXTRA_JSON (an object) is merged into the error entry.
|
|
51
|
+
pb_error() {
|
|
52
|
+
local code="$1" message="$2" extra="${3:-}"
|
|
53
|
+
[ -n "$extra" ] || extra='{}'
|
|
54
|
+
jq -nc --arg code "$code" --arg message "$message" --argjson extra "$extra" \
|
|
55
|
+
'{version:1, errors:[({code:$code, message:$message} + $extra)]}'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# pb_fail CODE MESSAGE [EXTRA_JSON] -> emit the error and exit with its code.
|
|
59
|
+
pb_fail() {
|
|
60
|
+
pb_error "$@"
|
|
61
|
+
exit "$(pb_exit_code "$1")"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# pb_now -> ISO-8601 UTC timestamp.
|
|
65
|
+
pb_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
66
|
+
|
|
67
|
+
# pb_json_valid FILE -> 0 when FILE parses as JSON.
|
|
68
|
+
pb_json_valid() { jq -e . "$1" >/dev/null 2>&1; }
|
|
69
|
+
|
|
70
|
+
# pb_atomic_write_json TARGET CMD [ARGS...]
|
|
71
|
+
# Run CMD with stdout captured into a unique temp file beside TARGET, then
|
|
72
|
+
# rename it over TARGET only when CMD exited 0 AND the output parses as JSON.
|
|
73
|
+
# Any failure removes the temp file and leaves TARGET byte-identical, so a
|
|
74
|
+
# reader never sees a partial document and a crashed writer leaves no residue.
|
|
75
|
+
# The in-flight temp file is published so pb_on_exit can remove it when a
|
|
76
|
+
# signal lands mid-write: PB_ATOMIC_PREFIX is set BEFORE mktemp runs (a trap
|
|
77
|
+
# can fire between mktemp returning and the assignment below, so the exact
|
|
78
|
+
# name may never be recorded), and it carries $$ so the sweep only ever
|
|
79
|
+
# touches this process's own temps.
|
|
80
|
+
PB_ATOMIC_TMP=""
|
|
81
|
+
PB_ATOMIC_PREFIX=""
|
|
82
|
+
pb_atomic_write_json() {
|
|
83
|
+
local target="$1"; shift
|
|
84
|
+
local dir tmp
|
|
85
|
+
dir=$(dirname "$target")
|
|
86
|
+
mkdir -p "$dir" || return 1
|
|
87
|
+
PB_ATOMIC_PREFIX="$dir/.$(basename "$target").tmp.$$."
|
|
88
|
+
if ! tmp=$(mktemp "${PB_ATOMIC_PREFIX}XXXXXX"); then
|
|
89
|
+
PB_ATOMIC_PREFIX=""; return 1
|
|
90
|
+
fi
|
|
91
|
+
PB_ATOMIC_TMP="$tmp"
|
|
92
|
+
if ! "$@" >"$tmp"; then
|
|
93
|
+
rm -f "$tmp"; PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""; return 1
|
|
94
|
+
fi
|
|
95
|
+
if ! pb_json_valid "$tmp"; then
|
|
96
|
+
rm -f "$tmp"; PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""; return 1
|
|
97
|
+
fi
|
|
98
|
+
if ! mv -f "$tmp" "$target"; then
|
|
99
|
+
rm -f "$tmp"; PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""; return 1
|
|
100
|
+
fi
|
|
101
|
+
PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# pb_retry_on_rc RETRY_RC ATTEMPTS CMD [ARGS...]
|
|
105
|
+
# Run CMD; while it returns exactly RETRY_RC and attempts remain, run it
|
|
106
|
+
# again. Returns CMD's last exit code. Used for "collect again once when the
|
|
107
|
+
# PR head moved under us": any other failure propagates on the first try.
|
|
108
|
+
pb_retry_on_rc() {
|
|
109
|
+
local retry_rc="$1" attempts="$2"; shift 2
|
|
110
|
+
local n=0 rc=0
|
|
111
|
+
while :; do
|
|
112
|
+
n=$((n + 1))
|
|
113
|
+
rc=0
|
|
114
|
+
"$@" || rc=$?
|
|
115
|
+
[ "$rc" -eq "$retry_rc" ] || return "$rc"
|
|
116
|
+
[ "$n" -lt "$attempts" ] || return "$rc"
|
|
117
|
+
echo "pr-babysit: attempt $n of $attempts returned $rc; retrying $1" >&2
|
|
118
|
+
done
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# pb_mktmpdir -> create the per-run scratch dir (removed by pb_on_exit).
|
|
122
|
+
pb_mktmpdir() {
|
|
123
|
+
PB_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/pr-babysit.XXXXXX") || return 1
|
|
124
|
+
export PB_TMPDIR
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# pb_on_exit — the one EXIT handler: release the slot lock (if held) and
|
|
128
|
+
# remove the scratch dir. Installed by pb_init; scripts never add a second
|
|
129
|
+
# EXIT trap. Runs on signals too (TERM/INT re-raise through EXIT).
|
|
130
|
+
pb_on_exit() {
|
|
131
|
+
local rc=$?
|
|
132
|
+
# A second signal must not re-enter `exit` and abort the cleanup midway.
|
|
133
|
+
trap '' TERM INT
|
|
134
|
+
if declare -F pb_lock_release >/dev/null 2>&1; then pb_lock_release; fi
|
|
135
|
+
[ -n "${PB_ATOMIC_TMP:-}" ] && rm -f "$PB_ATOMIC_TMP"
|
|
136
|
+
[ -n "${PB_ATOMIC_PREFIX:-}" ] && rm -f "${PB_ATOMIC_PREFIX}"*
|
|
137
|
+
[ -n "${PB_TMPDIR:-}" ] && [ -d "${PB_TMPDIR:-}" ] && rm -rf "$PB_TMPDIR"
|
|
138
|
+
return "$rc"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# pb_init — install the EXIT handler and forward TERM/INT into it.
|
|
142
|
+
pb_init() {
|
|
143
|
+
trap pb_on_exit EXIT
|
|
144
|
+
trap 'exit 143' TERM
|
|
145
|
+
trap 'exit 130' INT
|
|
146
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# gh.sh — bounded `gh` transport for the pr-babysit helper.
|
|
3
|
+
#
|
|
4
|
+
# Sourced after common.sh. Every GitHub call goes through pb_gh so timeouts,
|
|
5
|
+
# retries, and error classification live in one place:
|
|
6
|
+
#
|
|
7
|
+
# pb_run_with_timeout SECONDS OUT ERR CMD... run CMD, kill it after SECONDS
|
|
8
|
+
# pb_gh_classify RC ERR_FILE OUT_FILE -> transient | permanent | ok
|
|
9
|
+
# pb_gh OUT_FILE ARGS... gh ARGS with retry; stdout → OUT_FILE
|
|
10
|
+
# pb_gh_json_ok FILE -> 0 when FILE is JSON without errors[]
|
|
11
|
+
#
|
|
12
|
+
# GNU `timeout` is absent on macOS and `gh` has no timeout flag, so the
|
|
13
|
+
# watchdog is a background job plus a sleeping killer subshell — bash 3.2 has
|
|
14
|
+
# no `wait -n`, so the job's exit code is read from a status file it writes.
|
|
15
|
+
#
|
|
16
|
+
# Retry policy (spec §Architecture): 3 attempts with waits of 2 s and 4 s
|
|
17
|
+
# between them (the 8 s step of PB_GH_BACKOFF only applies when
|
|
18
|
+
# PB_GH_ATTEMPTS is raised), for timeouts, 5xx, connection errors, HTTP 429,
|
|
19
|
+
# and 403 whose body mentions the rate limit. Any other 4xx is permanent and
|
|
20
|
+
# never retried. Observed real
|
|
21
|
+
# shapes this classifies (2026-09-19, gh 2.101.0):
|
|
22
|
+
# refused host → rc 1, stderr `dial tcp 127.0.0.1:1: connect: connection refused`
|
|
23
|
+
# 404 → rc 1, stdout `{"message":"Not Found",…,"status":"404"}`, stderr `gh: Not Found (HTTP 404)`
|
|
24
|
+
# GraphQL miss → rc 1, stdout `{"data":{…null…},"errors":[{"type":"NOT_FOUND",…}]}`
|
|
25
|
+
|
|
26
|
+
PB_GH_TIMEOUT="${PB_GH_TIMEOUT:-60}"
|
|
27
|
+
PB_GH_ATTEMPTS="${PB_GH_ATTEMPTS:-3}"
|
|
28
|
+
PB_GH_BACKOFF="${PB_GH_BACKOFF:-2 4 8}"
|
|
29
|
+
# Exit code the watchdog reports for a killed command (mirrors GNU timeout).
|
|
30
|
+
PB_GH_RC_TIMEOUT=124
|
|
31
|
+
|
|
32
|
+
# pb_run_with_timeout SECONDS OUT ERR CMD... -> CMD's exit code, or 124 when
|
|
33
|
+
# the watchdog killed it. stdout/stderr go to the OUT/ERR files.
|
|
34
|
+
pb_run_with_timeout() {
|
|
35
|
+
local secs="$1" out="$2" err="$3"; shift 3
|
|
36
|
+
local rcfile wdfile pid watchdog sleeper rc
|
|
37
|
+
rcfile=$(mktemp "${PB_TMPDIR:-${TMPDIR:-/tmp}}/pb-rc.XXXXXX") || return 1
|
|
38
|
+
wdfile="$rcfile.wd"
|
|
39
|
+
: >"$rcfile"; : >"$wdfile"
|
|
40
|
+
# `set +e` inside: the caller's errexit would otherwise end the subshell
|
|
41
|
+
# before the exit code is recorded, and an empty rc file reads as a timeout.
|
|
42
|
+
(
|
|
43
|
+
set +e
|
|
44
|
+
"$@" >"$out" 2>"$err"
|
|
45
|
+
echo $? >"$rcfile"
|
|
46
|
+
) &
|
|
47
|
+
pid=$!
|
|
48
|
+
# The watchdog detaches from the caller's stdio and publishes its sleeper's
|
|
49
|
+
# pid: killing only the subshell would orphan `sleep`, which then holds any
|
|
50
|
+
# pipe the caller (bats, a `$(...)`) is waiting on until the timeout elapses.
|
|
51
|
+
(
|
|
52
|
+
sleep "$secs" &
|
|
53
|
+
echo $! >"$wdfile"
|
|
54
|
+
wait $!
|
|
55
|
+
kill "$pid" 2>/dev/null
|
|
56
|
+
) >/dev/null 2>&1 &
|
|
57
|
+
watchdog=$!
|
|
58
|
+
# Every wait/kill below may legitimately fail (job already gone, watchdog
|
|
59
|
+
# killed → wait returns 143); callers run under `set -e`, so none of them
|
|
60
|
+
# may be a bare statement.
|
|
61
|
+
wait "$pid" 2>/dev/null || true
|
|
62
|
+
sleeper=$(cat "$wdfile" 2>/dev/null || true)
|
|
63
|
+
[ -z "$sleeper" ] || kill "$sleeper" 2>/dev/null || true
|
|
64
|
+
kill "$watchdog" 2>/dev/null || true
|
|
65
|
+
wait "$watchdog" 2>/dev/null || true
|
|
66
|
+
rc=$(cat "$rcfile" 2>/dev/null || true)
|
|
67
|
+
rm -f "$rcfile" "$wdfile"
|
|
68
|
+
if [ -z "$rc" ]; then
|
|
69
|
+
echo "pr-babysit: timed out after ${secs}s: $*" >>"$err"
|
|
70
|
+
return "$PB_GH_RC_TIMEOUT"
|
|
71
|
+
fi
|
|
72
|
+
return "$rc"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# pb_gh_classify RC ERR_FILE OUT_FILE -> prints transient | permanent | ok.
|
|
76
|
+
pb_gh_classify() {
|
|
77
|
+
local rc="$1" err="$2" out="$3" text status
|
|
78
|
+
[ "$rc" -eq 0 ] && { echo ok; return 0; }
|
|
79
|
+
[ "$rc" -eq "$PB_GH_RC_TIMEOUT" ] && { echo transient; return 0; }
|
|
80
|
+
# `set -e` callers: every pipeline that may match nothing ends in `|| true`.
|
|
81
|
+
text=$( { cat "$err" 2>/dev/null; cat "$out" 2>/dev/null; } || true)
|
|
82
|
+
# HTTP status: gh prints "(HTTP NNN)" on stderr; REST bodies carry "status".
|
|
83
|
+
status=$(printf '%s' "$text" | grep -oE '\(HTTP [0-9]{3}\)' | head -1 | tr -dc '0-9' || true)
|
|
84
|
+
[ -n "$status" ] || status=$(jq -r '.status? // empty' "$out" 2>/dev/null | head -1 || true)
|
|
85
|
+
case "$status" in
|
|
86
|
+
5??|429) echo transient; return 0 ;;
|
|
87
|
+
403)
|
|
88
|
+
if printf '%s' "$text" | grep -qi 'rate limit'; then echo transient; else echo permanent; fi
|
|
89
|
+
return 0 ;;
|
|
90
|
+
4??) echo permanent; return 0 ;;
|
|
91
|
+
esac
|
|
92
|
+
if printf '%s' "$text" | grep -qiE 'connection refused|connection reset|no such host|i/o timeout|TLS handshake|unexpected EOF|EOF$|network is unreachable|temporary failure|timed out'; then
|
|
93
|
+
echo transient; return 0
|
|
94
|
+
fi
|
|
95
|
+
# GraphQL errors[] without an HTTP status (NOT_FOUND etc.) are permanent.
|
|
96
|
+
if jq -e '.errors? | type == "array" and length > 0' "$out" >/dev/null 2>&1; then
|
|
97
|
+
echo permanent; return 0
|
|
98
|
+
fi
|
|
99
|
+
echo permanent
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# pb_gh OUT_FILE ARGS... -> run `gh ARGS` with timeout + bounded retry.
|
|
103
|
+
# 0 on success (OUT_FILE holds stdout). Non-zero after the policy is
|
|
104
|
+
# exhausted or on a permanent error; sets PB_GH_ATTEMPTED, PB_GH_LAST_RC,
|
|
105
|
+
# PB_GH_LAST_ERR (first stderr line), PB_GH_CLASS for the caller's error.
|
|
106
|
+
pb_gh() {
|
|
107
|
+
local out="$1"; shift
|
|
108
|
+
local err attempt rc class delay i
|
|
109
|
+
err="$out.err"
|
|
110
|
+
PB_GH_ATTEMPTED=0; PB_GH_LAST_RC=0; PB_GH_LAST_ERR=""; PB_GH_CLASS=ok
|
|
111
|
+
attempt=0
|
|
112
|
+
while [ "$attempt" -lt "$PB_GH_ATTEMPTS" ]; do
|
|
113
|
+
attempt=$((attempt + 1))
|
|
114
|
+
PB_GH_ATTEMPTED=$attempt
|
|
115
|
+
rc=0
|
|
116
|
+
pb_run_with_timeout "$PB_GH_TIMEOUT" "$out" "$err" gh "$@" || rc=$?
|
|
117
|
+
class=$(pb_gh_classify "$rc" "$err" "$out")
|
|
118
|
+
PB_GH_LAST_RC=$rc; PB_GH_CLASS=$class
|
|
119
|
+
PB_GH_LAST_ERR=$(head -n 1 "$err" 2>/dev/null || true)
|
|
120
|
+
case "$class" in
|
|
121
|
+
ok) rm -f "$err"; return 0 ;;
|
|
122
|
+
permanent) return 1 ;;
|
|
123
|
+
esac
|
|
124
|
+
[ "$attempt" -lt "$PB_GH_ATTEMPTS" ] || break
|
|
125
|
+
i=0; delay=2
|
|
126
|
+
for delay in $PB_GH_BACKOFF; do
|
|
127
|
+
i=$((i + 1))
|
|
128
|
+
[ "$i" -eq "$attempt" ] && break
|
|
129
|
+
done
|
|
130
|
+
echo "pr-babysit: gh $1 attempt $attempt failed ($class: ${PB_GH_LAST_ERR:-rc $rc}); retrying in ${delay}s" >&2
|
|
131
|
+
sleep "$delay"
|
|
132
|
+
done
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# pb_gh_json_ok FILE -> 0 when FILE parses and carries no GraphQL errors[].
|
|
137
|
+
pb_gh_json_ok() {
|
|
138
|
+
jq -e 'type as $t | if $t == "object" then ((.errors? // []) | length) == 0 else true end' "$1" >/dev/null 2>&1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# pb_gh_fail SOURCE — emit api_error/invalid_json from the last pb_gh state
|
|
142
|
+
# and exit 3. SOURCE names the read that failed (pr, threads, comments, …).
|
|
143
|
+
pb_gh_fail() {
|
|
144
|
+
local source="$1" code=api_error
|
|
145
|
+
[ "${PB_GH_CLASS:-}" = invalid_json ] && code=invalid_json
|
|
146
|
+
pb_fail "$code" "gh ${source} failed after ${PB_GH_ATTEMPTED:-0} attempt(s): ${PB_GH_LAST_ERR:-rc ${PB_GH_LAST_RC:-?}}" \
|
|
147
|
+
"$(jq -nc --arg source "$source" --argjson attempts "${PB_GH_ATTEMPTED:-0}" \
|
|
148
|
+
--arg class "${PB_GH_CLASS:-}" --arg lastMessage "${PB_GH_LAST_ERR:-}" \
|
|
149
|
+
'{source:$source, attempts:$attempts, class:$class, lastMessage:$lastMessage}')"
|
|
150
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lock.sh — one-writer lock per slot state file.
|
|
3
|
+
#
|
|
4
|
+
# Sourced after common.sh. `mkdir` is atomic on every POSIX filesystem, so the
|
|
5
|
+
# lock is a directory beside the state file: <state-file>.lock/ holding `pid`
|
|
6
|
+
# and `since` (epoch seconds). Atomic rename alone does not stop two
|
|
7
|
+
# controllers from losing each other's updates — this does. A lock whose pid is
|
|
8
|
+
# dead or older than PB_LOCK_STALE_SECONDS is stale and reclaimed with a stderr
|
|
9
|
+
# note. Release is wired through pb_on_exit (common.sh) so a crash or signal
|
|
10
|
+
# never leaves a live lock behind.
|
|
11
|
+
|
|
12
|
+
PB_LOCK_STALE_SECONDS="${PB_LOCK_STALE_SECONDS:-600}"
|
|
13
|
+
PB_LOCK_DIR=""
|
|
14
|
+
# The lock dir this process is trying to take. A signal can land between
|
|
15
|
+
# `mkdir` succeeding and PB_LOCK_DIR being set; pb_lock_release uses this to
|
|
16
|
+
# recognise (and remove) such a half-written lock of its own on exit.
|
|
17
|
+
PB_LOCK_WANT=""
|
|
18
|
+
PB_LOCK_HOLDER_PID=""
|
|
19
|
+
PB_LOCK_HOLDER_SINCE=""
|
|
20
|
+
|
|
21
|
+
# pb_lock_path STATE_FILE -> the lock directory path.
|
|
22
|
+
pb_lock_path() { printf '%s.lock' "$1"; }
|
|
23
|
+
|
|
24
|
+
# _pb_lock_stale LOCKDIR -> 0 when the holder is dead or too old.
|
|
25
|
+
_pb_lock_stale() {
|
|
26
|
+
local lockdir="$1" pid since now
|
|
27
|
+
pid=$(cat "$lockdir/pid" 2>/dev/null || echo "")
|
|
28
|
+
since=$(cat "$lockdir/since" 2>/dev/null || echo "")
|
|
29
|
+
now=$(date +%s)
|
|
30
|
+
# A lock dir with no pid/since is a half-written lock from a crashed taker.
|
|
31
|
+
[ -n "$pid" ] && [ -n "$since" ] || return 0
|
|
32
|
+
case "$pid$since" in *[!0-9]*) return 0 ;; esac
|
|
33
|
+
if ! kill -0 "$pid" 2>/dev/null; then return 0; fi
|
|
34
|
+
[ $((now - since)) -gt "$PB_LOCK_STALE_SECONDS" ] && return 0
|
|
35
|
+
return 1
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# pb_lock_acquire STATE_FILE -> 0 and PB_LOCK_DIR set; 75 when a live holder
|
|
39
|
+
# owns it (PB_LOCK_HOLDER_PID/SINCE describe it). Reclaims a stale lock once.
|
|
40
|
+
pb_lock_acquire() {
|
|
41
|
+
local state_file="$1" lockdir attempt
|
|
42
|
+
lockdir=$(pb_lock_path "$state_file")
|
|
43
|
+
mkdir -p "$(dirname "$state_file")" || return 1
|
|
44
|
+
PB_LOCK_WANT="$lockdir"
|
|
45
|
+
for attempt in 1 2; do
|
|
46
|
+
if mkdir "$lockdir" 2>/dev/null; then
|
|
47
|
+
PB_LOCK_DIR="$lockdir"
|
|
48
|
+
printf '%s\n' "$$" >"$lockdir/pid"
|
|
49
|
+
date +%s >"$lockdir/since"
|
|
50
|
+
return 0
|
|
51
|
+
fi
|
|
52
|
+
PB_LOCK_HOLDER_PID=$(cat "$lockdir/pid" 2>/dev/null || echo "")
|
|
53
|
+
PB_LOCK_HOLDER_SINCE=$(cat "$lockdir/since" 2>/dev/null || echo "")
|
|
54
|
+
if [ "$attempt" -eq 1 ] && _pb_lock_stale "$lockdir"; then
|
|
55
|
+
echo "pr-babysit: reclaiming stale lock $lockdir (pid ${PB_LOCK_HOLDER_PID:-?}, since ${PB_LOCK_HOLDER_SINCE:-?})" >&2
|
|
56
|
+
rm -rf "$lockdir"
|
|
57
|
+
continue
|
|
58
|
+
fi
|
|
59
|
+
return 75
|
|
60
|
+
done
|
|
61
|
+
return 75
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# pb_lock_release — remove the lock only if this process took it.
|
|
65
|
+
pb_lock_release() {
|
|
66
|
+
if [ -n "$PB_LOCK_DIR" ]; then
|
|
67
|
+
if [ "$(cat "$PB_LOCK_DIR/pid" 2>/dev/null)" = "$$" ]; then
|
|
68
|
+
rm -rf "$PB_LOCK_DIR"
|
|
69
|
+
fi
|
|
70
|
+
elif [ -n "$PB_LOCK_WANT" ] && [ -d "$PB_LOCK_WANT" ] && [ ! -e "$PB_LOCK_WANT/pid" ]; then
|
|
71
|
+
# mkdir succeeded but a signal arrived before the pid was written: the
|
|
72
|
+
# dir is ours and empty — take it back rather than leave a stale lock.
|
|
73
|
+
rm -rf "$PB_LOCK_WANT"
|
|
74
|
+
fi
|
|
75
|
+
PB_LOCK_DIR=""; PB_LOCK_WANT=""
|
|
76
|
+
return 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# pb_lock_fail STATE_FILE — emit the structured `locked` error and exit 75.
|
|
80
|
+
pb_lock_fail() {
|
|
81
|
+
pb_fail locked "slot is held by another controller" \
|
|
82
|
+
"$(jq -nc --arg pid "$PB_LOCK_HOLDER_PID" --arg since "$PB_LOCK_HOLDER_SINCE" \
|
|
83
|
+
'{pid:($pid|tonumber? // null), since:($since|tonumber? // null)}')"
|
|
84
|
+
}
|