claude-dev-env 8.6.1 → 8.7.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 (102) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
  3. package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
  4. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
  5. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  6. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  7. package/bin/install.test.mjs +25 -0
  8. package/docs/high-trust-agent-delivery.md +132 -0
  9. package/hooks/advisory/refactor_guard.py +2 -2
  10. package/hooks/advisory/test_refactor_guard.py +21 -0
  11. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  12. package/hooks/blocking/code_rules_shared.py +4 -7
  13. package/hooks/blocking/conftest.py +22 -0
  14. package/hooks/blocking/state_description_blocker.py +10 -8
  15. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  16. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  17. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  18. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  19. package/hooks/blocking/test_code_rules_shared.py +18 -0
  20. package/hooks/blocking/test_conftest.py +35 -0
  21. package/hooks/blocking/test_state_description_blocker.py +16 -0
  22. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  23. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  24. package/hooks/git-hooks/test_config.py +10 -3
  25. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  26. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  27. package/hooks/git-hooks/verification_notice_state.py +3 -1
  28. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  29. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  30. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  31. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  32. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  33. package/hooks/validators/exempt_paths.py +3 -18
  34. package/hooks/validators/test_exempt_paths.py +20 -0
  35. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  36. package/package.json +1 -1
  37. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  38. package/rules/archiving-agent-config.md +10 -0
  39. package/rules/code-standards.md +1 -0
  40. package/rules/correction-lens.md +134 -0
  41. package/rules/flag-non-breaking-findings.md +11 -0
  42. package/rules/no-contrast-framing.md +68 -0
  43. package/rules/review-closure-is-a-check.md +44 -0
  44. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  45. package/scripts/agent_merge_check.py +381 -0
  46. package/scripts/check.ps1 +12 -1
  47. package/scripts/ci/install-playtest.mjs +291 -0
  48. package/scripts/ci/install-playtest.test.mjs +84 -0
  49. package/scripts/ci/installer-lifecycle.mjs +11 -30
  50. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  51. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  52. package/scripts/ci/scratch-home-install.mjs +73 -0
  53. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  54. package/scripts/codex_compat_materializer.py +33 -16
  55. package/scripts/contrast_framing.py +101 -0
  56. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  57. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  58. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  59. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  60. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  61. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  62. package/scripts/durable_post_lint.py +30 -4
  63. package/scripts/followup_cli.py +6 -10
  64. package/scripts/merge_gate_checks.py +236 -0
  65. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  66. package/scripts/policy_lint/adapters.py +30 -0
  67. package/scripts/policy_lint/config/constants.py +2 -0
  68. package/scripts/policy_lint/registry.py +5 -0
  69. package/scripts/repository_checks/config/constants.py +12 -0
  70. package/scripts/repository_checks/config/policy_document.py +28 -2
  71. package/scripts/repository_checks/followups.py +54 -0
  72. package/scripts/repository_checks/match_exemptions.py +67 -0
  73. package/scripts/repository_checks/models.py +46 -2
  74. package/scripts/repository_checks/reporting.py +23 -7
  75. package/scripts/repository_checks/tracked_secrets.py +2 -2
  76. package/scripts/repository_policy.py +4 -2
  77. package/scripts/review_closure.py +134 -0
  78. package/scripts/review_closure_github.py +345 -0
  79. package/scripts/review_closure_model.py +360 -0
  80. package/scripts/test_agent_merge_check.py +317 -0
  81. package/scripts/test_durable_post_lint.py +46 -0
  82. package/scripts/test_followup_cli.py +40 -0
  83. package/scripts/test_merge_gate_checks.py +92 -0
  84. package/scripts/tests/test_code_review_constants.py +78 -0
  85. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  86. package/scripts/tests/test_contrast_framing.py +117 -0
  87. package/scripts/tests/test_followups.py +88 -0
  88. package/scripts/tests/test_match_exemptions.py +291 -0
  89. package/scripts/tests/test_models.py +67 -2
  90. package/scripts/tests/test_package_inventory.py +6 -4
  91. package/scripts/tests/test_pester_suites_run.py +53 -0
  92. package/scripts/tests/test_policy_document.py +39 -1
  93. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  94. package/scripts/tests/test_reporting.py +18 -0
  95. package/scripts/tests/test_repository_policy.py +43 -1
  96. package/scripts/tests/test_review_closure.py +126 -0
  97. package/scripts/tests/test_review_closure_github.py +240 -0
  98. package/scripts/tests/test_review_closure_model.py +299 -0
  99. package/scripts/tests/test_tracked_secrets.py +42 -0
  100. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  101. package/scripts/repository_checks/email_exemptions.py +0 -39
  102. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -0,0 +1,134 @@
1
+ # Correction Lens
2
+
3
+ **Standing rule. This file is never archived and never removed.** The permanence
4
+ clause at the end of this document states what that binds.
5
+
6
+ **When this applies:** Every correction the user gives. A "no, do it this way", a
7
+ "stop writing that word", a repeated review comment, a fix the user makes by hand
8
+ after an agent hands work back. Each one is evidence that a control is missing,
9
+ and the correction is handled by building that control.
10
+
11
+ ## Rule
12
+
13
+ A correction runs through five layers, in this order, and the lesson lands at the
14
+ highest layer that can hold it. The change opens in the same run as the
15
+ correction.
16
+
17
+ | Priority | Layer | What holds the lesson | The lesson lands as |
18
+ |---|---|---|---|
19
+ | 1 | Codebase | The mistake is impossible by how the code is written | A type, a signature, a data structure, an API shape, a deleted branch |
20
+ | 2 | Static analysis | A program reads the tree and decides | A lint rule, an enforcer check, a paired test, a CI gate |
21
+ | 3 | Review tooling | A reviewer or a review bot reads the criterion | A `CODE_RULES.md` row, a `.cursor/BUGBOT.md` pointer, a Graphite or BugBot rule |
22
+ | 4 | Skill | An agent follows a procedure | A skill under the agents home |
23
+ | 5 | Style guide | Word choice and prose shape | A row in a `rules/*.md` file or a style document |
24
+
25
+ Layer 1 is the goal every time. A lesson encoded there needs no reader, no run,
26
+ and no agent to remember it. The wrong call has nowhere to live.
27
+
28
+ ## Choosing the layer
29
+
30
+ Three sentences close every correction:
31
+
32
+ 1. The layer chosen.
33
+ 2. Why each higher layer cannot hold this lesson.
34
+ 3. The change opened at the chosen layer, by pull request.
35
+
36
+ A layer holds a lesson when its own test passes:
37
+
38
+ - **Layer 1 holds it** when a type, a signature, or a data structure can make the
39
+ wrong call fail to compile, fail to construct, or fail to exist.
40
+ - **Layer 2 holds it** when a program reading the tree can separate right from
41
+ wrong with no judgment call.
42
+ - **Layer 3 holds it** when a reader needs the criterion in front of them and a
43
+ program cannot decide it.
44
+ - **Layer 4 holds it** when the lesson is a procedure with steps an agent runs.
45
+ - **Layer 5 holds it** when the lesson is word choice or the shape of prose.
46
+
47
+ Effort rules out no layer. A layer is ruled out when it lacks the capability to
48
+ hold the lesson, and the sentence that rules it out says which capability is
49
+ missing. "A lint for this would take a day" leaves layer 2 in play.
50
+
51
+ Two layers often hold one correction. Take both: the shape at layer 1 and the
52
+ check at layer 2 cost one run together and each covers what the other misses.
53
+
54
+ ## A repeated correction moves up a layer
55
+
56
+ The same correction arriving a second time is the measurement. The layer chosen
57
+ the first time was too low. Move the lesson one layer up and say so in the same
58
+ run.
59
+
60
+ A review comment that repeats across pull requests reads the same way. Each
61
+ repetition names the layer below the reviewer as the one that needs the control.
62
+ Count the repetitions.
63
+
64
+ ## Where the control lands
65
+
66
+ A control lands in this package, so every session that loads this environment
67
+ carries it, on any machine, under any model. A control written into one
68
+ project's instructions reaches that project alone, and a control written into a
69
+ chat reply reaches that conversation alone.
70
+
71
+ Memory holds the decision. The control holds the behavior. A correction that
72
+ produced a memory file and nothing else has been recorded and never encoded, so
73
+ the rule still binds. Open the control.
74
+
75
+ ## Trust grows as the controls catch the common mistakes
76
+
77
+ Add agents after the controls catch what agents get wrong, and start from a
78
+ workflow small enough to watch. Each repeated correction becomes a stronger
79
+ control, and the number of agents rises behind it.
80
+ [`docs/high-trust-agent-delivery.md`](../docs/high-trust-agent-delivery.md)
81
+ carries the model this comes from, including the layered-controls picture and
82
+ the inner and outer delivery loops.
83
+
84
+ ## Permanence
85
+
86
+ This file stays in `rules/`. A prune pass, an archive sweep, a consolidation, or
87
+ a rewrite that would move it into `rules-archived/` stops at this line. The
88
+ "Never archived" section of
89
+ [`packages/claude-dev-env/rules-archived/ARCHIVE-MANIFEST.md`](../rules-archived/ARCHIVE-MANIFEST.md)
90
+ names it, and
91
+ [`archiving-agent-config.md`](archiving-agent-config.md) names the same
92
+ exemption from the archiving procedure's side.
93
+
94
+ An edit that sharpens this rule is welcome. The file stays.
95
+
96
+ ## Excerpt for repository-instruction sessions
97
+
98
+ Codex reads its repository `AGENTS.md`; this excerpt supplies the standalone
99
+ contract.
100
+
101
+ ```
102
+ Correction handling for this run, from rules/correction-lens.md.
103
+
104
+ Every correction the user gives becomes a control. Run it through five layers
105
+ and land it at the highest one that can hold it:
106
+
107
+ 1. Codebase. The mistake is impossible by how the code is written: a type, a
108
+ signature, a data structure, an API shape.
109
+ 2. Static analysis. A program reads the tree and decides: a lint rule, an
110
+ enforcer check, a paired test, a CI gate.
111
+ 3. Review tooling. A reviewer or a review bot reads the criterion: a code-rules
112
+ row, a BUGBOT pointer, a Graphite rule.
113
+ 4. Skill. An agent follows a procedure.
114
+ 5. Style guide. Word choice and prose shape.
115
+
116
+ Close the correction with three sentences: the layer chosen, why each higher
117
+ layer cannot hold the lesson, and the change opened at that layer in this run.
118
+ Effort rules out no layer; a missing capability does, and you name which one.
119
+
120
+ The same correction arriving twice means the layer was too low. Move it up one
121
+ layer and say so.
122
+
123
+ Land the control in the shared environment package, so every session carries
124
+ it. A memory file records the decision and encodes nothing.
125
+ ```
126
+
127
+ ## Sibling rules
128
+
129
+ | Rule | Role |
130
+ |---|---|
131
+ | [`flag-non-breaking-findings.md`](flag-non-breaking-findings.md) | A gate blocks on a breaking finding and records a smell |
132
+ | [`code-standards.md`](code-standards.md) | The layer map of contract, pointer, enforcer, lint, session rules |
133
+ | [`archiving-agent-config.md`](archiving-agent-config.md) | How a rule leaves service, and which files are exempt |
134
+ | [`falsify-before-green.md`](falsify-before-green.md) | A new layer-2 check counts once it has run red on a named break |
@@ -97,6 +97,17 @@ loading, so the gate fails. A non-canonical filename and a Git mode other than
97
97
  `SEVERITY_BY_RULE_ID` in that module is the whole declaration. Add a check,
98
98
  add its row.
99
99
 
100
+ `scripts/repository_policy.py` splits the committed-tree checks the same way.
101
+ `SEVERITY_BY_CHECK_ID` in `repository_checks/config/constants.py` carries one
102
+ row per check id, and a check id with no row reads as breaking. The
103
+ `package-inventory` check is the one smell there. A production file whose
104
+ package inventory omits its row still imports and still runs, so the check
105
+ prints its finding with an `advisory:` prefix, records it in the ledger, and
106
+ leaves the tree passing. Every other committed-tree check blocks: a `CLAUDE.md`
107
+ naming a file that does not exist, an env-var row naming a file that never
108
+ reads the variable, a test outside the testpaths allowlist, and a tracked
109
+ secret.
110
+
100
111
  ## Sibling rules
101
112
 
102
113
  | Rule | Role |
@@ -0,0 +1,68 @@
1
+ # No Contrast Framing
2
+
3
+ **When this applies:** Every sentence a person reads. A chat reply, a commit
4
+ message, a pull request title or body, a review comment, an issue, a rule file,
5
+ a document, a plan, a memory file.
6
+
7
+ ## Rule
8
+
9
+ State what is true. Leave the rejected reading out.
10
+
11
+ A contrast defines its subject against something the writer already decided
12
+ against, so the reader carries two readings where one would do, and the second
13
+ one is the writer's own discarded draft. The sentence says more and shows less.
14
+
15
+ Six forms carry the whole ban. Each row names the form the check reports.
16
+
17
+ | Form | Shape | Write this |
18
+ |---|---|---|
19
+ | `trailing-comma-not` | `a diff regression, not shared infrastructure` | `a diff regression` |
20
+ | `corrective-it-is-not` | `it is not a flake, it is a bug` | `a bug`, then the evidence that shows it |
21
+ | `substitution-rather-than` | `park it rather than fix it` | `park it` |
22
+ | `additive-not-just` | `not just for today` | the point the sentence was building toward |
23
+ | `comparative-ranking` | `being precise matters more than trying to cover them` | `be precise about these three` |
24
+ | `substitution-as-opposed-to` | `a warning as opposed to a failure` | `a warning` |
25
+
26
+ A comparison of quantities stays. "The function runs more than 30 lines" counts
27
+ lines. The ban covers the comparison that ranks one course of action over
28
+ another the writer is turning down.
29
+
30
+ Three shapes stay quiet by design. A backticked span or a fenced block carries
31
+ an example, so the check blanks it first. A line opening with `>` quotes
32
+ someone else, whose words are theirs to write. A `, not` clause reports only
33
+ when no bare `not` already sits earlier in the line, so a list of clauses
34
+ reports its first `, not` and stays quiet on the rest, as in
35
+ `a diff regression, not shared code, not a workaround`. A list whose own
36
+ opening word is a bare `not`, as in "not in chat, not in a commit message, not
37
+ in a body", keeps even its first `, not` quiet. A release automation body
38
+ passes the post linter untouched, since the bot builds it from commit
39
+ subjects and reads it back as machine input.
40
+
41
+ When the sentence thins after the contrast comes out, the missing piece is
42
+ evidence. Name it: the failing check, the log line, the measured number, the
43
+ file and the line.
44
+
45
+ ## Where it is enforced
46
+
47
+ | Surface | What runs |
48
+ |---|---|
49
+ | Authored Markdown in the repository | The staged policy lint's `contrast-framing` rule, on every changed file, graded against the file's prior text |
50
+ | A pull request or issue title and body, and a comment | `scripts/durable_post_lint.py`, which every post passes through before it reaches GitHub |
51
+ | A chat reply | The writer, reading the sentence back before sending |
52
+
53
+ The pattern list lives in
54
+ `scripts/dev_env_scripts_constants/contrast_framing_constants.py`, and both
55
+ lints read that one list. A synchronization test requires a row in the table
56
+ above for every form the list carries.
57
+
58
+ A chat reply reaches no check, so the same list is what the writer reads the
59
+ sentence against: a comma followed by `not`, a `rather than`, a `not just`, a
60
+ ranking of one thing over another.
61
+
62
+ ## Sibling rules
63
+
64
+ | Rule | Role |
65
+ |---|---|
66
+ | [`asd-ste100-language.md`](asd-ste100-language.md) | Plain word choice, sentence style, and tone |
67
+ | [`correction-lens.md`](correction-lens.md) | Every correction becomes a control at the highest layer that can hold it |
68
+ | [`research-mode.md`](research-mode.md) | A claim carries its source |
@@ -0,0 +1,44 @@
1
+ # Review Closure Is a Check
2
+
3
+ **When this applies:** Any pull request an agent drives, from the first review comment on it to the merge.
4
+
5
+ ## Rule
6
+
7
+ A review finding on the head is answered before the pull request merges. The agent driving it replies, pushes the fix, or both. The check named `Review closure` reads that state on every push and on every review event, and reports red while a finding waits.
8
+
9
+ One command prints the same verdict:
10
+
11
+ ```
12
+ python packages/claude-dev-env/scripts/review_closure.py <owner>/<name> <number>
13
+ ```
14
+
15
+ It prints `CLOSED` and exits 0 when every finding on the head is answered. It prints `OPEN`, one line per waiting finding, and exits 1. It exits 2 when the state could not be read.
16
+
17
+ ## What closes a finding
18
+
19
+ | The thread | Closed by |
20
+ |---|---|
21
+ | A review comment on code | A push that replaced the code it points at |
22
+ | A review comment on code | A reply from the account driving the pull request |
23
+ | A review comment on code | Resolution, where the comment carries no red circle |
24
+ | A red-circle finding | A reply from the driving account, or a push that replaced the code |
25
+ | A thread the driving account opened | Itself |
26
+ | A blocking `Claude Approvals` row | A push, which moves the head the check reports on |
27
+
28
+ A red circle marks a finding a review states as blocking, so resolution in silence leaves it open. The reply says what changed or why the finding stands, and the reviewer reads it beside the diff.
29
+
30
+ The driving account is the one that opened the pull request. Where the agent comments under a second login, `--driver-login <login>` names it, repeatably.
31
+
32
+ ## Where the check runs
33
+
34
+ `.github/workflows/review-closure.yml` runs it here on a push to a pull request, on a submitted or dismissed review, and on a review comment. Each run reports on the pull request's head commit, so a finding posted after the last push still turns the check red.
35
+
36
+ `Echo-Visuals-Inc/python-automation` runs the same command from its own workflow, against the revision of this package that its workflow pins.
37
+
38
+ ## Sibling rules
39
+
40
+ | Rule | Role |
41
+ |---|---|
42
+ | [`agent-merges-its-own-green-pull-request.md`](agent-merges-its-own-green-pull-request.md) | The agent that drives a pull request merges it once its gate passes |
43
+ | [`git-workflow.md`](git-workflow.md) | Draft first, and confirm each required context fired after the push |
44
+ | [`correction-lens.md`](correction-lens.md) | A correction becomes a control at the highest layer that can hold it |
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env pwsh
2
+ <#
3
+ .SYNOPSIS
4
+ Run every Pester suite under a tests directory and exit on its verdict.
5
+
6
+ .DESCRIPTION
7
+ check.ps1 calls this script as a child process so Invoke-Tool reads a
8
+ process exit code the way it does for ruff, mypy and pytest. Pester is a
9
+ cmdlet and leaves $LASTEXITCODE alone, so running it in-process would
10
+ report every suite as passing.
11
+
12
+ A missing Pester module exits non-zero. A gate that cannot run its check
13
+ has not passed it, and a skip here would leave the suites reporting
14
+ nothing while the tree reads as covered.
15
+
16
+ .PARAMETER TestsRoot
17
+ Directory holding the *.Tests.ps1 suites.
18
+
19
+ .OUTPUTS
20
+ Pester's own detailed output. Exit code 0 when every suite passes.
21
+ #>
22
+ [CmdletBinding()]
23
+ param(
24
+ [Parameter(Mandatory = $true)]
25
+ [string]$TestsRoot
26
+ )
27
+
28
+ $ErrorActionPreference = 'Stop'
29
+
30
+ if (-not (Get-Module -ListAvailable -Name Pester)) {
31
+ Write-Error "Pester is not installed, so the suites under $TestsRoot cannot run."
32
+ exit 1
33
+ }
34
+
35
+ Import-Module Pester -MinimumVersion 5.0
36
+
37
+ $suiteConfiguration = New-PesterConfiguration
38
+ $suiteConfiguration.Run.Path = $TestsRoot
39
+ $suiteConfiguration.Run.Exit = $true
40
+ $suiteConfiguration.Output.Verbosity = 'Detailed'
41
+
42
+ Invoke-Pester -Configuration $suiteConfiguration
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env python3
2
+ """Report whether the agent driving a pull request may merge it now.
3
+
4
+ An agent that drives a pull request in this repository merges it once the
5
+ repository's own gate passes and no review thread is open. This command reads
6
+ that state from GitHub and prints one verdict line, so the decision rests on
7
+ the pull request's live state rather than on the agent's reading of it.
8
+
9
+ Usage::
10
+
11
+ python3 agent_merge_check.py jl-cmd/claude-dev-env 1442
12
+ -> MERGE jl-cmd/claude-dev-env#1442 ab845eb :: green, no open review
13
+ thread, ready for the agent to merge
14
+
15
+ Exit status is 0 for MERGE, 1 for HOLD, and 2 when the state could not be
16
+ read.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import functools
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from collections.abc import Callable, Mapping, Sequence
30
+
31
+ from dev_env_scripts_constants.agent_merge_check_constants import (
32
+ ACCEPT_HEADER,
33
+ ALL_HOLD_REASONS_BY_STATE,
34
+ ALL_OUTDATED_KEYS,
35
+ ALL_RESOLVED_KEYS,
36
+ ALL_THREAD_NODE_KEYS,
37
+ ALL_TOKEN_ENVIRONMENT_VARIABLES,
38
+ AUTHORIZATION_HEADER,
39
+ BEARER_PREFIX,
40
+ COMMAND_DESCRIPTION,
41
+ CONTENT_TYPE_HEADER,
42
+ DRAFT_HOLD_REASON,
43
+ DRAFT_KEY,
44
+ ERROR_EXIT_CODE,
45
+ GITHUB_ACCEPT_TYPE,
46
+ GITHUB_API_ROOT,
47
+ GITHUB_GRAPHQL_ENDPOINT,
48
+ HEAD_KEY,
49
+ HOLD_EXIT_CODE,
50
+ HOLD_VERDICT_LABEL,
51
+ JSON_CONTENT_TYPE,
52
+ MERGE_EXIT_CODE,
53
+ MERGE_VERDICT_LABEL,
54
+ MERGEABLE_STATE_CLEAN,
55
+ MERGEABLE_STATE_KEY,
56
+ MERGEABLE_STATE_UNKNOWN,
57
+ NAME_VARIABLE,
58
+ NO_SIGN_IN_MESSAGE,
59
+ NUMBER_ARGUMENT_HELP,
60
+ NUMBER_KEY,
61
+ NUMBER_VARIABLE,
62
+ OWNER_VARIABLE,
63
+ PAGE_SIZE_VARIABLE,
64
+ PULL_REQUEST_ENDPOINT_TEMPLATE,
65
+ QUERY_KEY,
66
+ READY_DETAIL,
67
+ REQUEST_TIMEOUT_SECONDS,
68
+ REVIEW_THREAD_PAGE_SIZE,
69
+ REVIEW_THREADS_ENDPOINT_TEMPLATE,
70
+ SETTLE_ATTEMPT_COUNT,
71
+ SETTLE_WAIT_SECONDS,
72
+ SHA_KEY,
73
+ SHORT_SHA_LENGTH,
74
+ SLUG_ARGUMENT_HELP,
75
+ SLUG_SEPARATOR,
76
+ UNKNOWN_STATE_HOLD_TEMPLATE,
77
+ UNRESOLVED_THREAD_QUERY,
78
+ UNRESOLVED_THREADS_HOLD_TEMPLATE,
79
+ UTF8_ENCODING,
80
+ VARIABLES_KEY,
81
+ VERDICT_LINE_TEMPLATE,
82
+ )
83
+
84
+
85
+ class MergeCheckError(Exception):
86
+ """Raised when the pull request state could not be read."""
87
+
88
+
89
+ def hold_reason(
90
+ all_pull_request_fields: Mapping[str, object],
91
+ unresolved_thread_count: int,
92
+ ) -> str | None:
93
+ """Return why this pull request stays open, or None when it may merge.
94
+
95
+ Args:
96
+ all_pull_request_fields: The pull request as the GitHub API reports
97
+ it, carrying its draft flag and its merge state.
98
+ unresolved_thread_count: How many review threads are open on it.
99
+
100
+ Returns:
101
+ The reason text for a draft, for a merge state other than clean, and
102
+ for an open review thread. None when the pull request is ready for
103
+ the agent that drives it to merge it.
104
+ """
105
+ if all_pull_request_fields.get(DRAFT_KEY):
106
+ return DRAFT_HOLD_REASON
107
+ merge_state = all_pull_request_fields.get(MERGEABLE_STATE_KEY)
108
+ if merge_state != MERGEABLE_STATE_CLEAN:
109
+ return ALL_HOLD_REASONS_BY_STATE.get(
110
+ merge_state,
111
+ UNKNOWN_STATE_HOLD_TEMPLATE.format(state=merge_state),
112
+ )
113
+ if unresolved_thread_count > 0:
114
+ return UNRESOLVED_THREADS_HOLD_TEMPLATE.format(count=unresolved_thread_count)
115
+ return None
116
+
117
+
118
+ def verdict_line(
119
+ slug: str,
120
+ all_pull_request_fields: Mapping[str, object],
121
+ reason: str | None,
122
+ ) -> str:
123
+ """Build the one line this command prints for a pull request.
124
+
125
+ Args:
126
+ slug: The repository as ``owner/name``.
127
+ all_pull_request_fields: The pull request the verdict describes.
128
+ reason: The hold reason, or None for a pull request that may merge.
129
+
130
+ Returns:
131
+ A line naming the verdict, the pull request, its head commit, and
132
+ either the hold reason or the ready detail.
133
+ """
134
+ all_head_fields = all_pull_request_fields.get(HEAD_KEY, {})
135
+ head_sha = ""
136
+ if isinstance(all_head_fields, Mapping):
137
+ head_sha = str(all_head_fields.get(SHA_KEY, ""))[:SHORT_SHA_LENGTH]
138
+ return VERDICT_LINE_TEMPLATE.format(
139
+ label=HOLD_VERDICT_LABEL if reason else MERGE_VERDICT_LABEL,
140
+ slug=slug,
141
+ number=all_pull_request_fields.get(NUMBER_KEY),
142
+ sha=head_sha,
143
+ detail=reason or READY_DETAIL,
144
+ )
145
+
146
+
147
+ def count_unresolved_threads(all_thread_records: Sequence[object]) -> int:
148
+ """Count the review threads that still wait on an answer.
149
+
150
+ Args:
151
+ all_thread_records: The review threads GitHub reports for the pull
152
+ request.
153
+
154
+ Returns:
155
+ How many of them are unresolved and still point at live code. An
156
+ outdated thread names code the pull request has since replaced, so it
157
+ holds nothing back.
158
+ """
159
+ return sum(
160
+ 1
161
+ for each_thread in all_thread_records
162
+ if isinstance(each_thread, Mapping)
163
+ and not _any_flag(each_thread, ALL_RESOLVED_KEYS)
164
+ and not _any_flag(each_thread, ALL_OUTDATED_KEYS)
165
+ )
166
+
167
+
168
+ def _any_flag(all_thread_fields: Mapping[str, object], all_keys: Sequence[str]) -> bool:
169
+ return any(all_thread_fields.get(each_key) for each_key in all_keys)
170
+
171
+
172
+ def _github_token() -> str:
173
+ for each_variable in ALL_TOKEN_ENVIRONMENT_VARIABLES:
174
+ token = os.environ.get(each_variable)
175
+ if token:
176
+ return token
177
+ raise MergeCheckError(NO_SIGN_IN_MESSAGE)
178
+
179
+
180
+ def _request_json(
181
+ url: str,
182
+ token: str,
183
+ all_payload_fields: Mapping[str, object] | None,
184
+ ) -> object:
185
+ request = urllib.request.Request(url)
186
+ request.add_header(ACCEPT_HEADER, GITHUB_ACCEPT_TYPE)
187
+ request.add_header(AUTHORIZATION_HEADER, BEARER_PREFIX + token)
188
+ body = None
189
+ if all_payload_fields is not None:
190
+ request.add_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE)
191
+ body = json.dumps(all_payload_fields).encode(UTF8_ENCODING)
192
+ try:
193
+ with urllib.request.urlopen(
194
+ request,
195
+ data=body,
196
+ timeout=REQUEST_TIMEOUT_SECONDS,
197
+ ) as answer:
198
+ return json.loads(answer.read().decode(UTF8_ENCODING))
199
+ except (urllib.error.URLError, ValueError) as failure:
200
+ raise MergeCheckError(str(failure)) from failure
201
+
202
+
203
+ def read_pull_request(slug: str, number: int, token: str) -> Mapping[str, object]:
204
+ """Read one pull request from the GitHub REST API.
205
+
206
+ Args:
207
+ slug: The repository as ``owner/name``.
208
+ number: The pull request number.
209
+ token: The GitHub token the request authenticates with.
210
+
211
+ Returns:
212
+ The pull request fields, carrying its draft flag, its merge state,
213
+ and its head commit.
214
+
215
+ Raises:
216
+ MergeCheckError: The API call failed, or answered with something
217
+ other than a pull request object.
218
+ """
219
+ document = _request_json(
220
+ PULL_REQUEST_ENDPOINT_TEMPLATE.format(
221
+ api_root=GITHUB_API_ROOT,
222
+ slug=slug,
223
+ number=number,
224
+ ),
225
+ token,
226
+ None,
227
+ )
228
+ if not isinstance(document, Mapping):
229
+ raise MergeCheckError(str(document))
230
+ return document
231
+
232
+
233
+ def read_unresolved_thread_count(slug: str, number: int, token: str) -> int:
234
+ """Read how many review threads on a pull request stay open.
235
+
236
+ Two routes carry the same fact. A Claude Code session reaches GitHub
237
+ through a proxy that refuses GraphQL and serves the review threads over a
238
+ REST route of its own, so this reads that route first and falls back to
239
+ the GraphQL query every other caller has.
240
+
241
+ Args:
242
+ slug: The repository as ``owner/name``.
243
+ number: The pull request number.
244
+ token: The GitHub token the request authenticates with.
245
+
246
+ Returns:
247
+ The count of unresolved, current review threads.
248
+
249
+ Raises:
250
+ MergeCheckError: Both routes failed, or both answered with a shape
251
+ that carries no review threads.
252
+ """
253
+ all_thread_records = _thread_records_over_rest(slug, number, token)
254
+ if all_thread_records is None:
255
+ return _read_unresolved_thread_count_over_graphql(slug, number, token)
256
+ return count_unresolved_threads(all_thread_records)
257
+
258
+
259
+ def _thread_records_over_rest(
260
+ slug: str,
261
+ number: int,
262
+ token: str,
263
+ ) -> list[object] | None:
264
+ try:
265
+ document = _request_json(
266
+ REVIEW_THREADS_ENDPOINT_TEMPLATE.format(
267
+ api_root=GITHUB_API_ROOT,
268
+ slug=slug,
269
+ number=number,
270
+ ),
271
+ token,
272
+ None,
273
+ )
274
+ except MergeCheckError:
275
+ return None
276
+ return document if isinstance(document, list) else None
277
+
278
+
279
+ def _thread_query_payload(owner: str, name: str, number: int) -> dict[str, object]:
280
+ return {
281
+ QUERY_KEY: UNRESOLVED_THREAD_QUERY,
282
+ VARIABLES_KEY: {
283
+ OWNER_VARIABLE: owner,
284
+ NAME_VARIABLE: name,
285
+ NUMBER_VARIABLE: number,
286
+ PAGE_SIZE_VARIABLE: REVIEW_THREAD_PAGE_SIZE,
287
+ },
288
+ }
289
+
290
+
291
+ def _read_unresolved_thread_count_over_graphql(
292
+ slug: str,
293
+ number: int,
294
+ token: str,
295
+ ) -> int:
296
+ owner, _, name = slug.partition(SLUG_SEPARATOR)
297
+ document = _request_json(
298
+ GITHUB_GRAPHQL_ENDPOINT,
299
+ token,
300
+ _thread_query_payload(owner, name, number),
301
+ )
302
+ all_thread_records = functools.reduce(
303
+ functools.partial(_field_at, document),
304
+ ALL_THREAD_NODE_KEYS,
305
+ document,
306
+ )
307
+ if not isinstance(all_thread_records, Sequence):
308
+ raise MergeCheckError(str(document))
309
+ return count_unresolved_threads(all_thread_records)
310
+
311
+
312
+ def _field_at(document: object, all_fields: object, key: str) -> object:
313
+ if not isinstance(all_fields, Mapping):
314
+ raise MergeCheckError(str(document))
315
+ return all_fields.get(key)
316
+
317
+
318
+ def read_settled_pull_request(
319
+ slug: str,
320
+ number: int,
321
+ token: str,
322
+ sleep: Callable[[float], None],
323
+ ) -> Mapping[str, object]:
324
+ """Read a pull request whose merge state GitHub has worked out.
325
+
326
+ GitHub reports ``unknown`` while it computes mergeability after a push,
327
+ so this reads again until the state settles.
328
+
329
+ Args:
330
+ slug: The repository as ``owner/name``.
331
+ number: The pull request number.
332
+ token: The GitHub token the request authenticates with.
333
+ sleep: How the caller waits between reads.
334
+
335
+ Returns:
336
+ The fields the last read reported.
337
+
338
+ Raises:
339
+ MergeCheckError: A read failed.
340
+ """
341
+ for each_attempt in range(SETTLE_ATTEMPT_COUNT):
342
+ all_pull_request_fields = read_pull_request(slug, number, token)
343
+ if all_pull_request_fields.get(MERGEABLE_STATE_KEY) != MERGEABLE_STATE_UNKNOWN:
344
+ return all_pull_request_fields
345
+ if each_attempt + 1 < SETTLE_ATTEMPT_COUNT:
346
+ sleep(SETTLE_WAIT_SECONDS)
347
+ return all_pull_request_fields
348
+
349
+
350
+ def main(all_arguments: Sequence[str]) -> int:
351
+ """Print the merge verdict for one pull request.
352
+
353
+ Args:
354
+ all_arguments: The command line, without the program name.
355
+
356
+ Returns:
357
+ Zero when the pull request may merge, one when it holds, and two
358
+ when its state could not be read.
359
+ """
360
+ parser = argparse.ArgumentParser(description=COMMAND_DESCRIPTION)
361
+ parser.add_argument("slug", help=SLUG_ARGUMENT_HELP)
362
+ parser.add_argument("number", type=int, help=NUMBER_ARGUMENT_HELP)
363
+ parsed = parser.parse_args(all_arguments)
364
+ try:
365
+ token = _github_token()
366
+ all_pull_request_fields = read_settled_pull_request(
367
+ parsed.slug, parsed.number, token, time.sleep
368
+ )
369
+ unresolved_thread_count = read_unresolved_thread_count(
370
+ parsed.slug, parsed.number, token
371
+ )
372
+ except MergeCheckError as failure:
373
+ print(failure, file=sys.stderr)
374
+ return ERROR_EXIT_CODE
375
+ reason = hold_reason(all_pull_request_fields, unresolved_thread_count)
376
+ print(verdict_line(parsed.slug, all_pull_request_fields, reason))
377
+ return HOLD_EXIT_CODE if reason else MERGE_EXIT_CODE
378
+
379
+
380
+ if __name__ == "__main__":
381
+ raise SystemExit(main(sys.argv[1:]))