carson 2.24.0 → 2.26.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.
- checksums.yaml +4 -4
- data/.github/workflows/carson_policy.yml +5 -14
- data/API.md +6 -36
- data/MANUAL.md +10 -38
- data/README.md +9 -22
- data/RELEASE.md +24 -0
- data/SKILL.md +5 -7
- data/VERSION +1 -1
- data/lib/carson/cli.rb +1 -59
- data/lib/carson/config.rb +25 -50
- data/lib/carson/runtime/audit.rb +2 -61
- data/lib/carson/runtime/govern.rb +7 -36
- data/lib/carson/runtime/local/hooks.rb +142 -0
- data/lib/carson/runtime/local/onboard.rb +356 -0
- data/lib/carson/runtime/local/prune.rb +292 -0
- data/lib/carson/runtime/local/sync.rb +93 -0
- data/lib/carson/runtime/local/template.rb +347 -0
- data/lib/carson/runtime/local.rb +6 -1225
- data/lib/carson/runtime/review/gate_support.rb +1 -1
- data/lib/carson/runtime/review/sweep_support.rb +1 -1
- data/lib/carson/runtime/review/utility.rb +2 -2
- data/lib/carson/runtime/setup.rb +8 -2
- data/lib/carson/runtime.rb +0 -1
- data/templates/.github/carson.md +0 -1
- metadata +6 -2
- data/lib/carson/runtime/lint.rb +0 -154
data/lib/carson/config.rb
CHANGED
|
@@ -7,16 +7,14 @@ module Carson
|
|
|
7
7
|
# Config is built-in only for outsider mode; host repositories do not carry Carson config files.
|
|
8
8
|
class Config
|
|
9
9
|
attr_accessor :git_remote
|
|
10
|
-
attr_reader :main_branch, :protected_branches, :
|
|
11
|
-
:template_managed_files, :
|
|
12
|
-
:lint_policy_source,
|
|
10
|
+
attr_reader :main_branch, :protected_branches, :hooks_path, :managed_hooks,
|
|
11
|
+
:template_managed_files, :template_canonical,
|
|
13
12
|
:review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
|
|
14
|
-
:review_sweep_states, :
|
|
13
|
+
:review_sweep_states, :review_disposition, :review_risk_keywords,
|
|
15
14
|
:review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
|
|
16
|
-
:ruby_indentation,
|
|
17
15
|
:audit_advisory_check_names,
|
|
18
16
|
:workflow_style,
|
|
19
|
-
:govern_repos, :
|
|
17
|
+
:govern_repos, :govern_auto_merge, :govern_merge_method,
|
|
20
18
|
:govern_agent_provider, :govern_dispatch_state_path,
|
|
21
19
|
:govern_check_wait
|
|
22
20
|
|
|
@@ -35,17 +33,13 @@ module Carson
|
|
|
35
33
|
"protected_branches" => [ "main", "master" ]
|
|
36
34
|
},
|
|
37
35
|
"hooks" => {
|
|
38
|
-
"
|
|
39
|
-
"
|
|
36
|
+
"path" => "~/.carson/hooks",
|
|
37
|
+
"managed" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
|
|
40
38
|
},
|
|
41
39
|
"template" => {
|
|
42
40
|
"managed_files" => [ ".github/carson.md", ".github/copilot-instructions.md", ".github/CLAUDE.md", ".github/AGENTS.md", ".github/pull_request_template.md" ],
|
|
43
|
-
"superseded_files" => [ ".github/carson-instructions.md", ".github/workflows/carson-lint.yml", ".github/.mega-linter.yml" ],
|
|
44
41
|
"canonical" => nil
|
|
45
42
|
},
|
|
46
|
-
"lint" => {
|
|
47
|
-
"policy_source" => "wanghailei/lint.git"
|
|
48
|
-
},
|
|
49
43
|
"workflow" => {
|
|
50
44
|
"style" => "branch"
|
|
51
45
|
},
|
|
@@ -54,7 +48,7 @@ module Carson
|
|
|
54
48
|
"wait_seconds" => 10,
|
|
55
49
|
"poll_seconds" => 15,
|
|
56
50
|
"max_polls" => 20,
|
|
57
|
-
"
|
|
51
|
+
"disposition" => "Disposition:",
|
|
58
52
|
"risk_keywords" => [ "bug", "security", "incorrect", "block", "fail", "regression" ],
|
|
59
53
|
"sweep" => {
|
|
60
54
|
"window_days" => 3,
|
|
@@ -70,10 +64,8 @@ module Carson
|
|
|
70
64
|
},
|
|
71
65
|
"govern" => {
|
|
72
66
|
"repos" => [],
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
"method" => "squash"
|
|
76
|
-
},
|
|
67
|
+
"auto_merge" => true,
|
|
68
|
+
"merge_method" => "squash",
|
|
77
69
|
"agent" => {
|
|
78
70
|
"provider" => "auto",
|
|
79
71
|
"codex" => {},
|
|
@@ -81,11 +73,8 @@ module Carson
|
|
|
81
73
|
},
|
|
82
74
|
"dispatch_state_path" => "~/.carson/govern/dispatch_state.json",
|
|
83
75
|
"check_wait" => 30
|
|
84
|
-
},
|
|
85
|
-
"style" => {
|
|
86
|
-
"ruby_indentation" => "tabs"
|
|
87
|
-
}
|
|
88
76
|
}
|
|
77
|
+
}
|
|
89
78
|
end
|
|
90
79
|
|
|
91
80
|
def self.load_global_config_data( repo_root: )
|
|
@@ -138,8 +127,8 @@ module Carson
|
|
|
138
127
|
def self.apply_env_overrides( data: )
|
|
139
128
|
copy = deep_dup_value( value: data )
|
|
140
129
|
hooks = fetch_hash_section( data: copy, key: "hooks" )
|
|
141
|
-
hooks_path = ENV.fetch( "
|
|
142
|
-
hooks[ "
|
|
130
|
+
hooks_path = ENV.fetch( "CARSON_HOOKS_PATH", "" ).to_s.strip
|
|
131
|
+
hooks[ "path" ] = hooks_path unless hooks_path.empty?
|
|
143
132
|
workflow = fetch_hash_section( data: copy, key: "workflow" )
|
|
144
133
|
workflow_style = ENV.fetch( "CARSON_WORKFLOW_STYLE", "" ).to_s.strip
|
|
145
134
|
workflow[ "style" ] = workflow_style unless workflow_style.empty?
|
|
@@ -147,8 +136,8 @@ module Carson
|
|
|
147
136
|
review[ "wait_seconds" ] = env_integer( key: "CARSON_REVIEW_WAIT_SECONDS", fallback: review.fetch( "wait_seconds" ) )
|
|
148
137
|
review[ "poll_seconds" ] = env_integer( key: "CARSON_REVIEW_POLL_SECONDS", fallback: review.fetch( "poll_seconds" ) )
|
|
149
138
|
review[ "max_polls" ] = env_integer( key: "CARSON_REVIEW_MAX_POLLS", fallback: review.fetch( "max_polls" ) )
|
|
150
|
-
|
|
151
|
-
review[ "
|
|
139
|
+
disposition = ENV.fetch( "CARSON_REVIEW_DISPOSITION", "" ).to_s.strip
|
|
140
|
+
review[ "disposition" ] = disposition unless disposition.empty?
|
|
152
141
|
sweep = fetch_hash_section( data: review, key: "sweep" )
|
|
153
142
|
sweep[ "window_days" ] = env_integer( key: "CARSON_REVIEW_SWEEP_WINDOW_DAYS", fallback: sweep.fetch( "window_days" ) )
|
|
154
143
|
states = env_string_array( key: "CARSON_REVIEW_SWEEP_STATES" )
|
|
@@ -158,20 +147,13 @@ module Carson
|
|
|
158
147
|
audit = fetch_hash_section( data: copy, key: "audit" )
|
|
159
148
|
advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
|
|
160
149
|
audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
|
|
161
|
-
lint = fetch_hash_section( data: copy, key: "lint" )
|
|
162
|
-
lint_policy_source_env = ENV.fetch( "CARSON_LINT_POLICY_SOURCE", "" ).to_s.strip
|
|
163
|
-
lint[ "policy_source" ] = lint_policy_source_env unless lint_policy_source_env.empty?
|
|
164
|
-
style = fetch_hash_section( data: copy, key: "style" )
|
|
165
|
-
ruby_indentation = ENV.fetch( "CARSON_RUBY_INDENTATION", "" ).to_s.strip
|
|
166
|
-
style[ "ruby_indentation" ] = ruby_indentation unless ruby_indentation.empty?
|
|
167
150
|
govern = fetch_hash_section( data: copy, key: "govern" )
|
|
168
151
|
govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
|
|
169
152
|
govern[ "repos" ] = govern_repos unless govern_repos.empty?
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
merge[ "authority" ] = ( govern_authority == "true" ) unless govern_authority.empty?
|
|
153
|
+
govern_auto_merge = ENV.fetch( "CARSON_GOVERN_AUTO_MERGE", "" ).to_s.strip
|
|
154
|
+
govern[ "auto_merge" ] = ( govern_auto_merge == "true" ) unless govern_auto_merge.empty?
|
|
173
155
|
govern_method = ENV.fetch( "CARSON_GOVERN_MERGE_METHOD", "" ).to_s.strip
|
|
174
|
-
|
|
156
|
+
govern[ "merge_method" ] = govern_method unless govern_method.empty?
|
|
175
157
|
agent = fetch_hash_section( data: govern, key: "agent" )
|
|
176
158
|
govern_provider = ENV.fetch( "CARSON_GOVERN_AGENT_PROVIDER", "" ).to_s.strip
|
|
177
159
|
agent[ "provider" ] = govern_provider unless govern_provider.empty?
|
|
@@ -203,15 +185,12 @@ module Carson
|
|
|
203
185
|
@main_branch = fetch_string( hash: fetch_hash( hash: data, key: "git" ), key: "main_branch" )
|
|
204
186
|
@protected_branches = fetch_string_array( hash: fetch_hash( hash: data, key: "git" ), key: "protected_branches" )
|
|
205
187
|
|
|
206
|
-
@
|
|
207
|
-
@
|
|
188
|
+
@hooks_path = fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "path" )
|
|
189
|
+
@managed_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "managed" )
|
|
208
190
|
|
|
209
191
|
@template_managed_files = fetch_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "managed_files" )
|
|
210
|
-
@template_superseded_files = fetch_optional_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "superseded_files" )
|
|
211
192
|
@template_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "template" ), key: "canonical" )
|
|
212
193
|
resolve_canonical_files!
|
|
213
|
-
lint_hash = fetch_hash( hash: data, key: "lint" )
|
|
214
|
-
@lint_policy_source = lint_hash.fetch( "policy_source", "" ).to_s.strip
|
|
215
194
|
|
|
216
195
|
workflow_hash = fetch_hash( hash: data, key: "workflow" )
|
|
217
196
|
@workflow_style = fetch_string( hash: workflow_hash, key: "style" ).downcase
|
|
@@ -220,7 +199,7 @@ module Carson
|
|
|
220
199
|
@review_wait_seconds = fetch_non_negative_integer( hash: review_hash, key: "wait_seconds" )
|
|
221
200
|
@review_poll_seconds = fetch_non_negative_integer( hash: review_hash, key: "poll_seconds" )
|
|
222
201
|
@review_max_polls = fetch_positive_integer( hash: review_hash, key: "max_polls" )
|
|
223
|
-
@
|
|
202
|
+
@review_disposition = fetch_string( hash: review_hash, key: "disposition" )
|
|
224
203
|
@review_risk_keywords = fetch_string_array( hash: review_hash, key: "risk_keywords" )
|
|
225
204
|
sweep_hash = fetch_hash( hash: review_hash, key: "sweep" )
|
|
226
205
|
@review_sweep_window_days = fetch_positive_integer( hash: sweep_hash, key: "window_days" )
|
|
@@ -231,14 +210,11 @@ module Carson
|
|
|
231
210
|
@review_bot_usernames = fetch_optional_string_array( hash: review_hash, key: "bot_usernames" )
|
|
232
211
|
audit_hash = fetch_hash( hash: data, key: "audit" )
|
|
233
212
|
@audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
|
|
234
|
-
style_hash = fetch_hash( hash: data, key: "style" )
|
|
235
|
-
@ruby_indentation = fetch_string( hash: style_hash, key: "ruby_indentation" ).downcase
|
|
236
213
|
|
|
237
214
|
govern_hash = fetch_hash( hash: data, key: "govern" )
|
|
238
215
|
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |p| safe_expand_path( p ) }
|
|
239
|
-
|
|
240
|
-
@
|
|
241
|
-
@govern_merge_method = fetch_string( hash: govern_merge_hash, key: "method" ).downcase
|
|
216
|
+
@govern_auto_merge = fetch_optional_boolean( hash: govern_hash, key: "auto_merge", default: true, key_path: "govern.auto_merge" )
|
|
217
|
+
@govern_merge_method = fetch_string( hash: govern_hash, key: "merge_method" ).downcase
|
|
242
218
|
govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
|
|
243
219
|
@govern_agent_provider = fetch_string( hash: govern_agent_hash, key: "provider" ).downcase
|
|
244
220
|
dispatch_path = govern_hash.fetch( "dispatch_state_path" ).to_s
|
|
@@ -254,16 +230,15 @@ module Carson
|
|
|
254
230
|
raise ConfigError, "git.remote cannot be empty" if git_remote.empty?
|
|
255
231
|
raise ConfigError, "git.main_branch cannot be empty" if main_branch.empty?
|
|
256
232
|
raise ConfigError, "git.protected_branches must include #{main_branch}" unless protected_branches.include?( main_branch )
|
|
257
|
-
raise ConfigError, "hooks.
|
|
258
|
-
raise ConfigError, "hooks.
|
|
259
|
-
raise ConfigError, "review.
|
|
233
|
+
raise ConfigError, "hooks.path cannot be empty" if hooks_path.empty?
|
|
234
|
+
raise ConfigError, "hooks.managed cannot be empty" if managed_hooks.empty?
|
|
235
|
+
raise ConfigError, "review.disposition cannot be empty" if review_disposition.empty?
|
|
260
236
|
raise ConfigError, "review.risk_keywords cannot be empty" if review_risk_keywords.empty?
|
|
261
237
|
raise ConfigError, "review.sweep.states must contain one or both of open, closed" if ( review_sweep_states - [ "open", "closed" ] ).any? || review_sweep_states.empty?
|
|
262
238
|
raise ConfigError, "review.sweep.states cannot contain duplicates" unless review_sweep_states.uniq.length == review_sweep_states.length
|
|
263
239
|
raise ConfigError, "review.tracking_issue.title cannot be empty" if review_tracking_issue_title.empty?
|
|
264
240
|
raise ConfigError, "review.tracking_issue.label cannot be empty" if review_tracking_issue_label.empty?
|
|
265
241
|
raise ConfigError, "workflow.style must be one of trunk, branch" unless [ "trunk", "branch" ].include?( workflow_style )
|
|
266
|
-
raise ConfigError, "style.ruby_indentation must be one of tabs, spaces, either" unless [ "tabs", "spaces", "either" ].include?( ruby_indentation )
|
|
267
242
|
raise ConfigError, "govern.merge.method must be one of merge, squash, rebase" unless [ "merge", "squash", "rebase" ].include?( govern_merge_method )
|
|
268
243
|
raise ConfigError, "govern.agent.provider must be one of auto, codex, claude" unless [ "auto", "codex", "claude" ].include?( govern_agent_provider )
|
|
269
244
|
end
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Carson
|
|
|
24
24
|
hooks_ok = hooks_health_report
|
|
25
25
|
unless hooks_ok
|
|
26
26
|
audit_state = "block"
|
|
27
|
-
audit_concise_problems << "Hooks: mismatch — run carson
|
|
27
|
+
audit_concise_problems << "Hooks: mismatch — run carson refresh."
|
|
28
28
|
end
|
|
29
29
|
puts_verbose ""
|
|
30
30
|
puts_verbose "[Main Sync Status]"
|
|
@@ -75,7 +75,6 @@ module Carson
|
|
|
75
75
|
puts_verbose ""
|
|
76
76
|
puts_verbose "[Default Branch CI Baseline (gh)]"
|
|
77
77
|
default_branch_baseline = default_branch_ci_baseline_report
|
|
78
|
-
audit_state = "block" if default_branch_baseline.fetch( :status ) == "block"
|
|
79
78
|
audit_state = "attention" if audit_state == "ok" && default_branch_baseline.fetch( :status ) != "ok"
|
|
80
79
|
baseline_st = default_branch_baseline.fetch( :status )
|
|
81
80
|
if baseline_st == "block"
|
|
@@ -83,7 +82,7 @@ module Carson
|
|
|
83
82
|
parts << "#{default_branch_baseline.fetch( :failing_count )} failing" if default_branch_baseline.fetch( :failing_count ).positive?
|
|
84
83
|
parts << "#{default_branch_baseline.fetch( :pending_count )} pending" if default_branch_baseline.fetch( :pending_count ).positive?
|
|
85
84
|
parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
|
|
86
|
-
audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — merge
|
|
85
|
+
audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — fix before merge."
|
|
87
86
|
elsif baseline_st == "attention"
|
|
88
87
|
parts = []
|
|
89
88
|
parts << "#{default_branch_baseline.fetch( :advisory_failing_count )} advisory failing" if default_branch_baseline.fetch( :advisory_failing_count ).positive?
|
|
@@ -115,64 +114,6 @@ module Carson
|
|
|
115
114
|
audit_state == "block" ? EXIT_BLOCK : EXIT_OK
|
|
116
115
|
end
|
|
117
116
|
|
|
118
|
-
# Thin focused command: show required-check status for the current branch's open PR.
|
|
119
|
-
# Always exits 0 for pending or passing so callers never see a false "Error: Exit code 8".
|
|
120
|
-
def check!
|
|
121
|
-
unless head_exists?
|
|
122
|
-
puts_line "Checks: no commits yet."
|
|
123
|
-
return EXIT_OK
|
|
124
|
-
end
|
|
125
|
-
unless gh_available?
|
|
126
|
-
puts_line "Checks: gh CLI not available."
|
|
127
|
-
return EXIT_ERROR
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
pr_stdout, pr_stderr, pr_success, = gh_run(
|
|
131
|
-
"pr", "view", current_branch,
|
|
132
|
-
"--json", "number,title,url"
|
|
133
|
-
)
|
|
134
|
-
unless pr_success
|
|
135
|
-
error_text = gh_error_text( stdout_text: pr_stdout, stderr_text: pr_stderr, fallback: "no open PR for branch #{current_branch}" )
|
|
136
|
-
puts_line "Checks: #{error_text}."
|
|
137
|
-
return EXIT_ERROR
|
|
138
|
-
end
|
|
139
|
-
pr_data = JSON.parse( pr_stdout )
|
|
140
|
-
pr_number = pr_data[ "number" ].to_s
|
|
141
|
-
|
|
142
|
-
checks_stdout, checks_stderr, checks_success, checks_exit = gh_run(
|
|
143
|
-
"pr", "checks", pr_number, "--required", "--json", "name,state,bucket,workflow,link"
|
|
144
|
-
)
|
|
145
|
-
if checks_stdout.to_s.strip.empty?
|
|
146
|
-
error_text = gh_error_text( stdout_text: checks_stdout, stderr_text: checks_stderr, fallback: "required checks unavailable" )
|
|
147
|
-
puts_line "Checks: #{error_text}."
|
|
148
|
-
return EXIT_ERROR
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
checks_data = JSON.parse( checks_stdout )
|
|
152
|
-
pending = checks_data.select { |e| e[ "bucket" ].to_s == "pending" }
|
|
153
|
-
failing = checks_data.select { |e| check_entry_failing?( entry: e ) }
|
|
154
|
-
total = checks_data.count
|
|
155
|
-
# gh exits 8 when required checks are still pending (not a failure).
|
|
156
|
-
is_pending = !checks_success && checks_exit == 8
|
|
157
|
-
|
|
158
|
-
if failing.any?
|
|
159
|
-
puts_line "Checks: FAIL (#{failing.count} of #{total} failing)."
|
|
160
|
-
normalise_check_entries( entries: failing ).each { |e| puts_line " #{e.fetch( :workflow )} / #{e.fetch( :name )} #{e.fetch( :link )}".strip }
|
|
161
|
-
return EXIT_BLOCK
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
if is_pending || pending.any?
|
|
165
|
-
puts_line "Checks: pending (#{total - pending.count} of #{total} complete)."
|
|
166
|
-
return EXIT_OK
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
puts_line "Checks: all passing (#{total} required)."
|
|
170
|
-
EXIT_OK
|
|
171
|
-
rescue JSON::ParserError => e
|
|
172
|
-
puts_line "Checks: invalid gh response (#{e.message})."
|
|
173
|
-
EXIT_ERROR
|
|
174
|
-
end
|
|
175
|
-
|
|
176
117
|
private
|
|
177
118
|
def pr_and_check_report
|
|
178
119
|
report = {
|
|
@@ -81,18 +81,6 @@ module Carson
|
|
|
81
81
|
EXIT_OK
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Standalone housekeep: sync + prune.
|
|
85
|
-
def housekeep!
|
|
86
|
-
puts_verbose ""
|
|
87
|
-
puts_verbose "[Housekeep]"
|
|
88
|
-
sync_status = sync!
|
|
89
|
-
if sync_status != EXIT_OK
|
|
90
|
-
puts_line "housekeep: sync returned #{sync_status}; skipping prune."
|
|
91
|
-
return sync_status
|
|
92
|
-
end
|
|
93
|
-
prune!
|
|
94
|
-
end
|
|
95
|
-
|
|
96
84
|
private
|
|
97
85
|
|
|
98
86
|
# Resolves the list of repo paths to govern from config.
|
|
@@ -209,10 +197,6 @@ module Carson
|
|
|
209
197
|
return [ TRIAGE_REVIEW_BLOCKED, "changes requested by reviewer" ]
|
|
210
198
|
end
|
|
211
199
|
|
|
212
|
-
# Run audit and review gate checks for deeper analysis
|
|
213
|
-
audit_status, audit_detail = check_audit_status( pr: pr, repo_path: repo_path )
|
|
214
|
-
return [ TRIAGE_NEEDS_ATTENTION, audit_detail ] unless audit_status == :pass
|
|
215
|
-
|
|
216
200
|
review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
|
|
217
201
|
return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
|
|
218
202
|
|
|
@@ -245,20 +229,6 @@ module Carson
|
|
|
245
229
|
[ "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED" ].include?( state.upcase )
|
|
246
230
|
end
|
|
247
231
|
|
|
248
|
-
# Checks if the PR's branch is available locally and defers audit. Returns [:pass/:fail, detail].
|
|
249
|
-
def check_audit_status( pr:, repo_path: )
|
|
250
|
-
branch = pr[ "headRefName" ].to_s
|
|
251
|
-
stdout_text, stderr_text, status = Open3.capture3(
|
|
252
|
-
"git", "rev-parse", "--verify", "refs/heads/#{branch}",
|
|
253
|
-
chdir: repo_path
|
|
254
|
-
)
|
|
255
|
-
unless status.success?
|
|
256
|
-
return [ :pass, "branch not local; skipping audit" ]
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
[ :pass, "audit deferred to merge gate" ]
|
|
260
|
-
end
|
|
261
|
-
|
|
262
232
|
# Checks review gate status. Returns [:pass/:fail, detail].
|
|
263
233
|
def check_review_gate_status( pr:, repo_path: )
|
|
264
234
|
review_decision = pr[ "reviewDecision" ].to_s.upcase
|
|
@@ -308,7 +278,7 @@ module Carson
|
|
|
308
278
|
|
|
309
279
|
# Merges a PR that has passed all gates.
|
|
310
280
|
def merge_if_ready!( pr:, repo_path: )
|
|
311
|
-
unless config.
|
|
281
|
+
unless config.govern_auto_merge
|
|
312
282
|
puts_line " merge authority disabled; skipping merge"
|
|
313
283
|
return
|
|
314
284
|
end
|
|
@@ -373,14 +343,15 @@ module Carson
|
|
|
373
343
|
puts_line " agent result: #{result.status} — #{result.summary.to_s[0, 120]}"
|
|
374
344
|
end
|
|
375
345
|
|
|
376
|
-
# Runs
|
|
346
|
+
# Runs sync + prune in the given repo after a successful merge.
|
|
377
347
|
def housekeep_repo!( repo_path: )
|
|
378
|
-
if repo_path == self.repo_root
|
|
379
|
-
|
|
348
|
+
rt = if repo_path == self.repo_root
|
|
349
|
+
self
|
|
380
350
|
else
|
|
381
|
-
|
|
382
|
-
rt.housekeep!
|
|
351
|
+
Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err )
|
|
383
352
|
end
|
|
353
|
+
sync_status = rt.sync!
|
|
354
|
+
rt.prune! if sync_status == EXIT_OK
|
|
384
355
|
end
|
|
385
356
|
|
|
386
357
|
# Selects which agent provider to use based on config and availability.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module Carson
|
|
2
|
+
class Runtime
|
|
3
|
+
module Local
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
# Installs required hook files and enforces repository hook path.
|
|
7
|
+
def prepare!
|
|
8
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
9
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
10
|
+
|
|
11
|
+
FileUtils.mkdir_p( hooks_dir )
|
|
12
|
+
missing_templates = config.managed_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
|
|
13
|
+
unless missing_templates.empty?
|
|
14
|
+
puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
|
|
15
|
+
return EXIT_BLOCK
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
symlinked = symlink_hook_files
|
|
19
|
+
unless symlinked.empty?
|
|
20
|
+
puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
|
|
21
|
+
return EXIT_BLOCK
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
config.managed_hooks.each do |hook_name|
|
|
25
|
+
source_path = hook_template_path( hook_name: hook_name )
|
|
26
|
+
target_path = File.join( hooks_dir, hook_name )
|
|
27
|
+
FileUtils.cp( source_path, target_path )
|
|
28
|
+
FileUtils.chmod( 0o755, target_path )
|
|
29
|
+
puts_verbose "hook_written: #{relative_path( target_path )}"
|
|
30
|
+
end
|
|
31
|
+
git_system!( "config", "core.hooksPath", hooks_dir )
|
|
32
|
+
File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
|
|
33
|
+
puts_verbose "configured_hooks_path: #{hooks_dir}"
|
|
34
|
+
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
35
|
+
EXIT_OK
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Canonical hook template location inside Carson repository.
|
|
39
|
+
def hook_template_path( hook_name: )
|
|
40
|
+
File.join( tool_root, "hooks", hook_name )
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reports full hook health and can enforce stricter action messaging in `check`.
|
|
44
|
+
def hooks_health_report( strict: false )
|
|
45
|
+
configured = configured_hooks_path
|
|
46
|
+
expected = hooks_dir
|
|
47
|
+
hooks_path_ok = print_hooks_path_status( configured: configured, expected: expected )
|
|
48
|
+
print_required_hook_status
|
|
49
|
+
hooks_integrity = hook_integrity_state
|
|
50
|
+
hooks_ok = hooks_integrity_ok?( hooks_integrity: hooks_integrity )
|
|
51
|
+
print_hook_action(
|
|
52
|
+
strict: strict,
|
|
53
|
+
hooks_ok: hooks_path_ok && hooks_ok,
|
|
54
|
+
hooks_path_ok: hooks_path_ok,
|
|
55
|
+
configured: configured,
|
|
56
|
+
expected: expected
|
|
57
|
+
)
|
|
58
|
+
hooks_path_ok && hooks_ok
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def print_hooks_path_status( configured:, expected: )
|
|
62
|
+
configured_abs = configured.nil? ? nil : File.expand_path( configured )
|
|
63
|
+
hooks_path_ok = configured_abs == expected
|
|
64
|
+
puts_verbose "hooks_path: #{configured || '(unset)'}"
|
|
65
|
+
puts_verbose "hooks_path_expected: #{expected}"
|
|
66
|
+
puts_verbose( hooks_path_ok ? "hooks_path_status: ok" : "hooks_path_status: attention" )
|
|
67
|
+
hooks_path_ok
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def print_required_hook_status
|
|
71
|
+
required_hook_paths.each do |path|
|
|
72
|
+
exists = File.file?( path )
|
|
73
|
+
symlink = File.symlink?( path )
|
|
74
|
+
executable = exists && !symlink && File.executable?( path )
|
|
75
|
+
puts_verbose "hook_file: #{relative_path( path )} exists=#{exists} symlink=#{symlink} executable=#{executable}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def hook_integrity_state
|
|
80
|
+
{
|
|
81
|
+
missing: missing_hook_files,
|
|
82
|
+
non_executable: non_executable_hook_files,
|
|
83
|
+
symlinked: symlink_hook_files
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def hooks_integrity_ok?( hooks_integrity: )
|
|
88
|
+
missing_ok = hooks_integrity.fetch( :missing ).empty?
|
|
89
|
+
non_executable_ok = hooks_integrity.fetch( :non_executable ).empty?
|
|
90
|
+
symlinked_ok = hooks_integrity.fetch( :symlinked ).empty?
|
|
91
|
+
missing_ok && non_executable_ok && symlinked_ok
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def print_hook_action( strict:, hooks_ok:, hooks_path_ok:, configured:, expected: )
|
|
95
|
+
return if hooks_ok
|
|
96
|
+
|
|
97
|
+
if strict && !hooks_path_ok
|
|
98
|
+
configured_text = configured.to_s.strip
|
|
99
|
+
if configured_text.empty?
|
|
100
|
+
puts_verbose "ACTION: hooks path is unset (expected=#{expected})."
|
|
101
|
+
else
|
|
102
|
+
puts_verbose "ACTION: hooks path mismatch (configured=#{configured_text}, expected=#{expected})."
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
message = strict ? "ACTION: run carson refresh to align hooks with Carson #{Carson::VERSION}." : "ACTION: run carson refresh to enforce local main protections."
|
|
106
|
+
puts_verbose message
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reads configured core.hooksPath and normalises empty values to nil.
|
|
110
|
+
def configured_hooks_path
|
|
111
|
+
stdout_text, = git_capture_soft( "config", "--get", "core.hooksPath" )
|
|
112
|
+
value = stdout_text.to_s.strip
|
|
113
|
+
value.empty? ? nil : value
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Fully-qualified required hook file locations in the target repository.
|
|
117
|
+
def required_hook_paths
|
|
118
|
+
config.managed_hooks.map { |name| File.join( hooks_dir, name ) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Missing required hook files.
|
|
122
|
+
def missing_hook_files
|
|
123
|
+
required_hook_paths.reject { |path| File.file?( path ) }.map { |path| relative_path( path ) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Required hook files that exist but are not executable.
|
|
127
|
+
def non_executable_hook_files
|
|
128
|
+
required_hook_paths.select { |path| File.file?( path ) && !File.executable?( path ) }.map { |path| relative_path( path ) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Symlink hooks are disallowed to prevent bypassing managed hook content.
|
|
132
|
+
def symlink_hook_files
|
|
133
|
+
required_hook_paths.select { |path| File.symlink?( path ) }.map { |path| relative_path( path ) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Local directory where managed hooks are installed.
|
|
137
|
+
def hooks_dir
|
|
138
|
+
File.expand_path( File.join( config.hooks_path, Carson::VERSION ) )
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|