claude-dev-env 1.39.0 → 1.41.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.
Files changed (60) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
  3. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
  5. package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
  6. package/_shared/pr-loop/scripts/preflight.py +129 -2
  7. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
  9. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
  11. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  14. package/agents/pr-description-writer.md +150 -52
  15. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  16. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  18. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  19. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  20. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  21. package/hooks/blocking/pr_description_enforcer.py +56 -23
  22. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  23. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  24. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  25. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  26. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  27. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  28. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  29. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  30. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  31. package/hooks/config/pr_description_enforcer_constants.py +19 -0
  32. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  33. package/hooks/hooks.json +40 -0
  34. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  35. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  36. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  37. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  38. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  39. package/package.json +1 -1
  40. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  41. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  42. package/skills/bugteam/SKILL.md +28 -10
  43. package/skills/bugteam/reference/audit-contract.md +22 -0
  44. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  45. package/skills/bugteam/reference/team-setup.md +5 -0
  46. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  47. package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
  48. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  49. package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
  50. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  51. package/skills/copilot-review/SKILL.md +16 -0
  52. package/skills/findbugs/SKILL.md +35 -7
  53. package/skills/monitor-open-prs/SKILL.md +2 -1
  54. package/skills/pr-converge/SKILL.md +11 -3
  55. package/skills/pr-converge/config/constants.py +3 -1
  56. package/skills/pr-converge/reference/per-tick.md +17 -0
  57. package/skills/pr-converge/reference/state-schema.md +36 -8
  58. package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
  59. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  60. package/skills/qbug/SKILL.md +33 -8
@@ -118,18 +118,26 @@ def test_default_settings_file_mode_used_when_settings_file_missing(
118
118
  assert returned_mode == DEFAULT_SETTINGS_FILE_MODE
119
119
 
120
120
 
121
- def test_is_valid_project_root_helper_is_not_orphaned_in_common_module() -> None:
122
- """The orphan helper in the common module must be removed.
121
+ def test_is_valid_project_root_exported_from_consumer_modules(
122
+ tmp_path: Path,
123
+ ) -> None:
124
+ """is_valid_project_root behaviour matches across both consumers.
123
125
 
124
- Both grant and revoke keep their own local copies and consume them from
125
- module scope; the common-module copy was dead code with zero call sites.
126
+ Grant and revoke each define their own local copy of the helper, so
127
+ both copies must agree on the .git / .claude marker contract.
126
128
  """
127
- assert not hasattr(common_module, "is_valid_project_root"), (
128
- "is_valid_project_root must not live in _claude_permissions_common — "
129
- "neither grant nor revoke imports it from there"
130
- )
131
- assert callable(grant_module.is_valid_project_root)
132
- assert callable(revoke_module.is_valid_project_root)
129
+ git_marker_project_root = tmp_path / "git_project"
130
+ (git_marker_project_root / ".git").mkdir(parents=True)
131
+ claude_marker_project_root = tmp_path / "claude_project"
132
+ (claude_marker_project_root / ".claude").mkdir(parents=True)
133
+ bare_directory = tmp_path / "no_marker"
134
+ bare_directory.mkdir()
135
+ assert grant_module.is_valid_project_root(git_marker_project_root) is True
136
+ assert grant_module.is_valid_project_root(claude_marker_project_root) is True
137
+ assert grant_module.is_valid_project_root(bare_directory) is False
138
+ assert revoke_module.is_valid_project_root(git_marker_project_root) is True
139
+ assert revoke_module.is_valid_project_root(claude_marker_project_root) is True
140
+ assert revoke_module.is_valid_project_root(bare_directory) is False
133
141
 
134
142
 
135
143
  def _reload_with_stale_config_cache(module_name: str) -> ModuleType:
@@ -21,6 +21,22 @@ The user is on a PR branch, wants Copilot (the GitHub Copilot reviewer bot) to k
21
21
 
22
22
  ## The Process
23
23
 
24
+ ### Step 0: Opt-out check
25
+
26
+ Before any other work, inspect the `CLAUDE_REVIEWS_DISABLED` environment
27
+ variable. Treat the value as a comma-separated list of skill tokens
28
+ (case-insensitive, whitespace-tolerant). When the parsed list contains
29
+ `copilot`, respond with the literal line `/copilot-review is disabled via
30
+ CLAUDE_REVIEWS_DISABLED.` and stop — do not spawn the subagent, do not call
31
+ the Copilot reviewer API, do not run any other step of this skill.
32
+
33
+ PowerShell probe (Windows):
34
+
35
+ ```pwsh
36
+ $disabled = ($env:CLAUDE_REVIEWS_DISABLED -split ',' | ForEach-Object { $_.Trim().ToLowerInvariant() })
37
+ if ($disabled -contains 'copilot') { '/copilot-review is disabled via CLAUDE_REVIEWS_DISABLED.' }
38
+ ```
39
+
24
40
  ### Step 1: Gather PR context
25
41
 
26
42
  From the current repo:
@@ -18,6 +18,16 @@ User types `/findbugs` or asks for a bug audit on the current branch's PR. Typic
18
18
 
19
19
  If the current branch has no associated PR and no diff against the default branch, say so and stop. Do not invent scope.
20
20
 
21
+ ## Refusals
22
+
23
+ First match wins; respond with the quoted line exactly and stop:
24
+
25
+ - **Disabled via environment.** When `CLAUDE_REVIEWS_DISABLED` contains the
26
+ token `bugteam` (comma-separated, case-insensitive, whitespace-tolerant):
27
+ `/findbugs is disabled via CLAUDE_REVIEWS_DISABLED.` `/findbugs` is a PR
28
+ bug-audit skill in the same family as `/bugteam` and `/qbug`, so the
29
+ shared `bugteam` token disables all three.
30
+
21
31
  ## The Process
22
32
 
23
33
  ### Step 1: Resolve PR scope
@@ -120,13 +130,31 @@ returns findings without posting them as inline comments is invisible
120
130
  to the gate. Findbugs remains read-only on code — the review post is
121
131
  the only side effect.
122
132
 
123
- **Self-PR precondition.** GitHub rejects both `APPROVE` and
124
- `REQUEST_CHANGES` reviews when the authenticated identity matches the
125
- PR author with HTTP 422; `post_audit_thread.py` retries and then exits 2.
126
- To run findbugs on a PR you authored, switch `gh auth` to an alternate
127
- reviewer identity (a separate GitHub account) BEFORE invoking the skill.
128
- Without this switch, exit 2 is a hard halt there is no automated
129
- fallback path. The script does not auto-downgrade on the self-PR case.
133
+ **Self-PR auto-toggle.** GitHub rejects both `APPROVE` and
134
+ `REQUEST_CHANGES` reviews with HTTP 422 when the authenticated identity
135
+ matches the PR author ("Cannot approve/request changes on your own pull
136
+ request"). `post_audit_thread.py` detects this case via `gh api user` +
137
+ `gh api repos/<o>/<r>/pulls/<n>` and auto-resolves an alternate gh
138
+ account's token for the reviews POST the active `gh auth` account is
139
+ not mutated; only the bearer token sent on the request changes. After
140
+ the POST the active account is still whoever it was before, so no
141
+ "swap back" step is needed.
142
+
143
+ Configuration:
144
+
145
+ - `GH_TOKEN` / `GITHUB_TOKEN` env vars take precedence over the toggle.
146
+ Set them when you need to pin a specific reviewer identity by token
147
+ rather than by account login.
148
+ - `BUGTEAM_REVIEWER_ACCOUNT` env var names which authenticated alternate
149
+ to prefer when a toggle is needed (for example,
150
+ `BUGTEAM_REVIEWER_ACCOUNT=jl-cmd`). The env var name is shared across
151
+ every skill that invokes `post_audit_thread.py`. When unset, the
152
+ script falls back to the first alternate account `gh auth status`
153
+ reports.
154
+ - The named alternate must be logged in (`gh auth login -h github.com -u
155
+ <login>`) before the audit skill runs. The script exits 1 with a
156
+ pointing-at-`gh auth login` message when self-PR is detected and no
157
+ usable alternate is authenticated.
130
158
 
131
159
  After the agent (and Haiku secondary) return and the merge is complete,
132
160
  serialize the merged findings to a JSON file and call
@@ -27,6 +27,7 @@ description: >-
27
27
 
28
28
  Refusals — first match wins; respond with the quoted line exactly and stop:
29
29
 
30
+ - **Disabled via environment.** When `CLAUDE_REVIEWS_DISABLED` (comma-separated, case-insensitive, whitespace-tolerant) contains the token `bugteam`: `/monitor-open-prs is a /bugteam dispatcher and /bugteam is disabled via CLAUDE_REVIEWS_DISABLED.`
30
31
  - **GitHub API not accessible.** `get_me failed. /monitor-open-prs needs active GitHub MCP credentials.`
31
32
  - **Dirty tree on the caller's repo.** `Uncommitted changes detected. Stash, commit, or revert before /monitor-open-prs.`
32
33
  - **Required subagents missing.** Confirm `code-quality-agent` and `clean-coder` exist. Else: `Required subagent type <name> not installed.`
@@ -40,7 +41,7 @@ Call `scripts/discover_open_prs.discover_open_prs(all_owners=["jl-cmd", "JonEcho
40
41
  For each discovered PR:
41
42
 
42
43
  1. Resolve the PR's repo checkout (existing worktree or fresh `git clone`).
43
- 2. From that checkout, invoke `/bugteam --bugbot-retrigger <pr_number>`.
44
+ 2. From that checkout, invoke `/bugteam --bugbot-retrigger <pr_number>`. When `CLAUDE_REVIEWS_DISABLED` (comma-separated, case-insensitive, whitespace-tolerant) contains the token `bugbot`, omit `--bugbot-retrigger` from the dispatched command so the bugbot leg sits out the run.
44
45
  3. The `--bugbot-retrigger` flag tells bugteam to post `bugbot run` as an issue comment after every successful FIX push so Cursor's bugbot re-evaluates the new commit.
45
46
  4. Bugteam runs its own 20-loop audit/fix cycle per PR; this skill waits for each bugteam invocation to return before dispatching the next (or fanning out — see below).
46
47
 
@@ -38,7 +38,8 @@ clean-at SHAs. On tick exit, write updated state before calling ScheduleWakeup
38
38
  so the next tick resumes with accurate state.
39
39
 
40
40
  Fields: `phase`, `tick_count`, `bugbot_clean_at`, `bugteam_clean_at`,
41
- `copilot_clean_at`, `current_head`, `bugbot_acknowledged_at`, `bugbot_down`.
41
+ `copilot_clean_at`, `current_head`, `bugbot_acknowledged_at`, `bugbot_down`,
42
+ `bugteam_skill_invoked_at_head`, `bugteam_skill_invoked_at_tick`.
42
43
 
43
44
  ## Gotchas
44
45
 
@@ -136,7 +137,9 @@ no longer applies.
136
137
  - [ ] `bugbot_clean_at = current_head`
137
138
  - [ ] Advance to Step 5
138
139
  - [ ] **no review yet / commit_id mismatch** →
139
- - [ ] Run `python ~/.claude/skills/pr-converge/scripts/check_bugbot_ci.py --owner <O> --repo <R> --check-active --sha <current_head>`
140
+ - [ ] Run `python ~/.claude/skills/pr-converge/scripts/check_bugbot_ci.py --owner <O> --repo <R> --check-clean --sha <current_head>`
141
+ - [ ] Exit 0 (bugbot CI completed with success/neutral conclusion and no review = silent pass) → `bugbot_clean_at = current_head` → advance to Step 5
142
+ - [ ] Exit 1 (not a silent pass) or Exit 2 (gh CLI error — silent pass not confirmable) → Run `python ~/.claude/skills/pr-converge/scripts/check_bugbot_ci.py --owner <O> --repo <R> --check-active --sha <current_head>`
140
143
  - [ ] Exit 0 (already queued) → schedule 360s wakeup → return to Step 4 next tick
141
144
  - [ ] Exit 1 → post exactly `bugbot run` via `add_issue_comment` (no `@cursor[bot]` mention, no other text), wait 8s
142
145
  - [ ] Run `python ~/.claude/skills/pr-converge/scripts/check_bugbot_ci.py --owner <O> --repo <R> --sha <current_head>`
@@ -148,7 +151,12 @@ no longer applies.
148
151
 
149
152
  Pre-condition: `bugbot_clean_at == current_head` (or `bugbot_down == true`).
150
153
 
151
- Run `Skill({skill: "bugteam", args: "<PR URL>"})`.
154
+ Step 5 advances ONLY after `Skill({skill: "bugteam", args: "<PR URL>"})`
155
+ fires this tick. Substituting an `Agent({subagent_type: "clean-coder"})`
156
+ audit call for the formal Skill invocation is a protocol violation — the
157
+ `pr_converge_bugteam_enforcer` hook blocks it. `qbug` is NOT an accepted
158
+ substitute; `bugteam` is the only allowed skill at this step.
159
+
152
160
  After bugteam completes, re-resolve HEAD.
153
161
 
154
162
  - [ ] **bugteam pushed new commits** →
@@ -2,7 +2,8 @@
2
2
 
3
3
  All runtime and API constants live here. Script-specific constants
4
4
  (CLI args, markdown patterns, reflow settings) stay in
5
- ``scripts/config/pr_converge_constants.py``, which imports from here.
5
+ ``packages/claude-dev-env/skills/pr-converge/scripts/config/pr_converge_constants.py``,
6
+ which imports from here.
6
7
  """
7
8
 
8
9
  CURSOR_BOT_LOGIN = "cursor[bot]"
@@ -27,6 +28,7 @@ BUGBOT_DIRTY_BODY_REGEX = (
27
28
  )
28
29
  BUGBOT_CHECK_RUN_NAME_SUBSTRING = "bugbot"
29
30
  ALL_BUGBOT_CHECK_RUN_ACTIVE_STATUSES = ("queued", "in_progress")
31
+ BUGBOT_CHECK_RUN_COMPLETED_STATUS = "completed"
30
32
  ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS = ("success", "neutral")
31
33
  BUGBOT_RUN_TRIGGER_PHRASE = "bugbot run\n"
32
34
  BUGBOT_RUN_TRIGGER_WAIT_SECONDS = 8
@@ -207,6 +207,14 @@ BUGBOT.
207
207
 
208
208
  ## Step 3: Re-trigger bugbot
209
209
 
210
+ - [ ] **Opt-out gate.** When `CLAUDE_REVIEWS_DISABLED` (comma-separated,
211
+ case-insensitive, whitespace-tolerant) contains `bugbot`, set
212
+ `bugbot_down = true`, skip every check below, set `phase = BUGTEAM`,
213
+ and continue BUGTEAM in the same tick. The downstream loop branches on
214
+ `bugbot_down` exactly the way it does when bugbot CI is unavailable.
215
+ - [ ] **Silent-pass pre-check.** Run `python ~/.claude/skills/pr-converge/scripts/check_bugbot_ci.py --check-clean --owner <O> --repo <R> --sha <current_head>`
216
+ - [ ] Exit 0 → bugbot CI completed clean with no review (silent pass); set `bugbot_clean_at = current_head`, `phase = BUGTEAM`, continue BUGTEAM same tick
217
+ - [ ] Exit 1 (not a silent pass) or Exit 2 (gh CLI error — silent pass not confirmable) → continue with the trigger flow below
210
218
  - [ ] Run `python ~/.claude/skills/pr-converge/scripts/check_bugbot_ci.py --check-active --owner <O> --repo <R> --sha <current_head>`
211
219
  - [ ] Exit 0 → bugbot already queued on this commit; skip posting, wait for completion
212
220
  - [ ] Exit 1 → post trigger via `add_issue_comment(owner="OWNER", repo="REPO", issueNumber=NUMBER, body="bugbot run")`
@@ -215,6 +223,15 @@ BUGBOT.
215
223
  - [ ] Exit non-zero → bugbot is down; set `bugbot_down = true`, `phase = BUGTEAM`, continue BUGTEAM same tick
216
224
  - [ ] Exit 0 (check run present) → record `bugbot_acknowledged_at = <now ISO 8601>`, proceed to Step 4
217
225
 
226
+ The silent-pass pre-check fires FIRST so we never re-trigger a bot that
227
+ already finished cleanly. Cursor Bugbot communicates "no findings" by
228
+ completing the CI check with `conclusion: success` (or `neutral`) and
229
+ posting no review. The pre-check treats that outcome as
230
+ `bugbot_clean_at = current_head`, equivalent to an explicit clean
231
+ review. Without it, the trigger flow would re-prompt a bot that has
232
+ already evaluated this commit and refuses to re-run, and the bypass
233
+ branch would falsely mark `bugbot_down = true`.
234
+
218
235
  `bugbot run` is empirically the only re-trigger Cursor Bugbot recognizes;
219
236
  alternative phrasings silently no-op.
220
237
 
@@ -1,10 +1,13 @@
1
1
  # State across ticks
2
2
 
3
- **Dual persistence:** `<TMPDIR>/pr-converge-<session_id>/state.json`
4
- exists (multi-PR) that file is source of truth for `phase`, heads,
5
- counters, status, not conversation transcript. No `state.json` (typical
6
- single-PR `/pr-converge`) track in each assistant turn as
7
- plain text so next tick re-reads from context:
3
+ **Dual persistence:** Single-PR `/pr-converge` writes loop state to
4
+ `$CLAUDE_JOB_DIR/pr-converge-state.json`; that file is the source of truth
5
+ for `phase`, heads, counters, status. Multi-PR mode additionally maintains
6
+ `<TMPDIR>/pr-converge-<session_id>/state.json` for orchestrator coordination
7
+ across PRs. Both files share most of the fields below; the
8
+ `bugteam_skill_invoked_at_head` and `bugteam_skill_invoked_at_tick` fields
9
+ live ONLY in the single-PR `$CLAUDE_JOB_DIR/pr-converge-state.json` file
10
+ (see those field entries below for details).
8
11
 
9
12
  - `phase`: `BUGBOT`, `BUGTEAM`, or `COPILOT_WAIT`. Start `BUGBOT` on first tick.
10
13
  - `bugbot_clean_at`: HEAD SHA where bugbot last reported clean, or `null`.
@@ -34,7 +37,32 @@ plain text so next tick re-reads from context:
34
37
  (c) reads this field to decide between "schedule next wakeup" and
35
38
  "escalate to bugbot-down".
36
39
  - `tick_count`: integer, init `0`. Increment every tick.
40
+ - `bugteam_skill_invoked_at_head`: HEAD SHA (string) at which the formal
41
+ `Skill({skill: "bugteam"})` was last invoked, or `null`. Stamped by the
42
+ `pr_converge_bugteam_skill_tracker` hook on every formal bugteam Skill
43
+ invocation. **On-disk location:** the tracker writes this field to
44
+ `$CLAUDE_JOB_DIR/pr-converge-state.json` (single-PR mode); it is NOT
45
+ mirrored into the multi-PR `<TMPDIR>/pr-converge-<session_id>/state.json`
46
+ file. Operators inspecting these stamps must read the single-PR
47
+ `pr-converge-state.json` under `$CLAUDE_JOB_DIR`. Reset by overwrite on
48
+ the next bugteam Skill invocation; staleness is detected by the head/tick
49
+ equality check rather than by explicit reset. The
50
+ `pr_converge_bugteam_enforcer` hook reads this field together with
51
+ `current_head` to confirm the formal Skill registered at the current HEAD
52
+ before allowing follow-on clean-coder audit-shaped Agent spawns. `qbug`
53
+ invocations deliberately do NOT update this field.
54
+ - `bugteam_skill_invoked_at_tick`: integer tick number at which the formal
55
+ bugteam Skill was last invoked, or `null`. Companion to
56
+ `bugteam_skill_invoked_at_head` and persisted to the same
57
+ `$CLAUDE_JOB_DIR/pr-converge-state.json` file (single-PR mode only).
58
+ Reset by overwrite on the next bugteam Skill invocation; staleness is
59
+ detected by the head/tick equality check rather than by explicit reset.
60
+ The enforcer requires this value to equal the current `tick_count` so a
61
+ Skill invocation from a prior tick cannot wave through clean-coder
62
+ audit-shaped Agent spawns on a later tick at the same HEAD.
37
63
 
38
- Tick begins reading prior state line from most recent assistant message
39
- (no `state.json`) and ends by emitting updated state line; with
40
- `state.json`, follow `multi-pr-orchestration.md` §What orchestrator does per tick.
64
+ Single-PR tick begins by reading `$CLAUDE_JOB_DIR/pr-converge-state.json`
65
+ if it exists and ends by writing the updated state back to that same file
66
+ before scheduling the next wakeup. Multi-PR mode additionally coordinates
67
+ across PRs via `<TMPDIR>/pr-converge-<session_id>/state.json` per
68
+ `multi-pr-orchestration.md` §What orchestrator does per tick.
@@ -2,11 +2,23 @@
2
2
 
3
3
  Usage:
4
4
  python scripts/check_bugbot_ci.py --owner <O> --repo <R> --sha <SHA>
5
+ python scripts/check_bugbot_ci.py --owner <O> --repo <R> --sha <SHA> --check-active
6
+ python scripts/check_bugbot_ci.py --owner <O> --repo <R> --sha <SHA> --check-clean
5
7
 
6
- Exit codes:
8
+ Default mode (no flag):
7
9
  0 — bugbot check run found (printed to stdout as JSON)
8
10
  1 — no bugbot check run found
9
11
  EXIT_CODE_GH_ERROR — gh CLI error
12
+
13
+ ``--check-active`` mode:
14
+ 0 — bugbot check run is queued or in_progress
15
+ 1 — bugbot check run is absent or no longer active
16
+
17
+ ``--check-clean`` mode (silent-pass detection):
18
+ 0 — bugbot check run is completed with success/neutral conclusion
19
+ 1 — bugbot check run is absent, still active, or completed with a
20
+ non-clean conclusion (failure, action_required, etc.)
21
+ EXIT_CODE_GH_ERROR — gh CLI error
10
22
  """
11
23
 
12
24
  from __future__ import annotations
@@ -23,6 +35,8 @@ if str(_pr_converge_dir) not in sys.path:
23
35
 
24
36
  from config.constants import (
25
37
  ALL_BUGBOT_CHECK_RUN_ACTIVE_STATUSES,
38
+ ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
39
+ BUGBOT_CHECK_RUN_COMPLETED_STATUS,
26
40
  BUGBOT_CHECK_RUN_NAME_SUBSTRING,
27
41
  CHECK_RUNS_PER_PAGE,
28
42
  EXIT_CODE_GH_ERROR,
@@ -121,6 +135,74 @@ def is_bugbot_run_active(*, owner: str, repo: str, sha: str) -> bool:
121
135
  return False
122
136
 
123
137
 
138
+ def _classify_bugbot_check_run(
139
+ completed_process: subprocess.CompletedProcess[str],
140
+ ) -> bool | None:
141
+ """Classify the bugbot check run state from a gh API process result.
142
+
143
+ Args:
144
+ completed_process: Result of calling ``_run_check_runs_api``.
145
+
146
+ Returns:
147
+ True when the captured stdout contains a bugbot check run with a
148
+ ``completed`` status and a conclusion in
149
+ ``ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS``. False when no such
150
+ check run is present (absent, still active, or completed with a
151
+ non-clean conclusion). None when ``completed_process.returncode``
152
+ is non-zero, signalling a gh CLI failure that the caller must
153
+ surface separately from "not clean".
154
+ """
155
+ if completed_process.returncode != 0:
156
+ return None
157
+ for each_line in completed_process.stdout.splitlines():
158
+ stripped_line = each_line.strip()
159
+ if not stripped_line:
160
+ continue
161
+ try:
162
+ check_entry: dict[str, object] = json.loads(stripped_line)
163
+ except json.JSONDecodeError:
164
+ continue
165
+ each_name: object = check_entry.get("name")
166
+ if not isinstance(each_name, str):
167
+ continue
168
+ if BUGBOT_CHECK_RUN_NAME_SUBSTRING.lower() not in each_name.lower():
169
+ continue
170
+ each_status: object = check_entry.get("status")
171
+ if each_status != BUGBOT_CHECK_RUN_COMPLETED_STATUS:
172
+ return False
173
+ each_conclusion: object = check_entry.get("conclusion")
174
+ return (
175
+ isinstance(each_conclusion, str)
176
+ and each_conclusion in ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS
177
+ )
178
+ return False
179
+
180
+
181
+ def is_bugbot_run_clean(*, owner: str, repo: str, sha: str) -> bool | None:
182
+ """Check whether bugbot has a completed check run with a clean conclusion.
183
+
184
+ A "silent pass" is bugbot's signal that it found no issues: the CI
185
+ check run completes with a ``success`` or ``neutral`` conclusion and
186
+ no review comment is posted. This function detects that signal so
187
+ callers can treat it as equivalent to an explicit clean review.
188
+
189
+ Args:
190
+ owner: GitHub repository owner.
191
+ repo: GitHub repository name.
192
+ sha: Commit SHA to check.
193
+
194
+ Returns:
195
+ True when a bugbot check run is completed with a conclusion in
196
+ ``ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS``. False when the
197
+ check run is absent, still active, or completed with a non-clean
198
+ conclusion. None when the gh CLI returns an error so the caller
199
+ can distinguish a transient API failure from a "not clean"
200
+ result.
201
+ """
202
+ completed_process = _run_check_runs_api(owner=owner, repo=repo, sha=sha)
203
+ return _classify_bugbot_check_run(completed_process)
204
+
205
+
124
206
  def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
125
207
  """Parse command-line arguments.
126
208
 
@@ -128,18 +210,28 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
128
210
  all_argv: Command-line argument list.
129
211
 
130
212
  Returns:
131
- Parsed namespace with owner, repo, and sha.
213
+ Parsed namespace with owner, repo, sha, and mode flags.
132
214
  """
133
215
  parser = argparse.ArgumentParser(description=__doc__)
134
216
  parser.add_argument("--owner", required=True, help="GitHub repository owner")
135
217
  parser.add_argument("--repo", required=True, help="GitHub repository name")
136
218
  parser.add_argument("--sha", required=True, help="Commit SHA to check")
137
- parser.add_argument(
219
+ mode_group = parser.add_mutually_exclusive_group()
220
+ mode_group.add_argument(
138
221
  "--check-active",
139
222
  action="store_true",
140
223
  default=False,
141
224
  help="Check for active (queued/in-progress) check runs only",
142
225
  )
226
+ mode_group.add_argument(
227
+ "--check-clean",
228
+ action="store_true",
229
+ default=False,
230
+ help=(
231
+ "Check for a completed bugbot check run with a "
232
+ "success/neutral conclusion (silent-pass detection)"
233
+ ),
234
+ )
143
235
  return parser.parse_args(all_argv)
144
236
 
145
237
 
@@ -150,19 +242,32 @@ def main(all_arguments: list[str]) -> int:
150
242
  all_arguments: Command-line arguments.
151
243
 
152
244
  Returns:
153
- 0 when a bugbot check run is found, 1 when absent,
154
- EXIT_CODE_GH_ERROR on error.
245
+ Exit code per the mode-specific contract documented in the
246
+ module docstring.
155
247
  """
156
248
  arguments = parse_arguments(all_arguments)
249
+ if arguments.check_clean:
250
+ completed_process = _run_check_runs_api(
251
+ owner=arguments.owner,
252
+ repo=arguments.repo,
253
+ sha=arguments.sha,
254
+ )
255
+ if completed_process.returncode != 0:
256
+ print(f"gh api error: {completed_process.stderr}", file=sys.stderr)
257
+ return EXIT_CODE_GH_ERROR
258
+ is_clean = _classify_bugbot_check_run(completed_process)
259
+ if is_clean is not True:
260
+ print("bugbot: not clean")
261
+ return 0 if is_clean is True else 1
157
262
  if arguments.check_active:
158
- found = is_bugbot_run_active(
263
+ is_active = is_bugbot_run_active(
159
264
  owner=arguments.owner,
160
265
  repo=arguments.repo,
161
266
  sha=arguments.sha,
162
267
  )
163
- if not found:
268
+ if not is_active:
164
269
  print("bugbot: not found")
165
- return 0 if found else 1
270
+ return 0 if is_active else 1
166
271
  return check_bugbot_ci(
167
272
  owner=arguments.owner,
168
273
  repo=arguments.repo,