shaka 0.1.0.pre.1
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 +7 -0
- data/LICENSE +22 -0
- data/README.md +63 -0
- data/bin/install +44 -0
- data/docs/control-towers.md +128 -0
- data/docs/getting-started.md +138 -0
- data/docs/host-support.md +117 -0
- data/docs/packaging.md +60 -0
- data/docs/pilot-plan.md +129 -0
- data/docs/review.md +202 -0
- data/docs/usage-reporting.md +100 -0
- data/docs/verification.md +104 -0
- data/docs/working-with-your-agent.md +253 -0
- data/exe/shaka +4 -0
- data/exe/shaka-install +4 -0
- data/skills/shaka/SKILL.md +212 -0
- data/skills/shaka/lib/shaka/checkpoint.rb +129 -0
- data/skills/shaka/lib/shaka/claude_usage.rb +112 -0
- data/skills/shaka/lib/shaka/cost_estimate.rb +130 -0
- data/skills/shaka/lib/shaka/error.rb +6 -0
- data/skills/shaka/lib/shaka/github.rb +146 -0
- data/skills/shaka/lib/shaka/merge.rb +105 -0
- data/skills/shaka/lib/shaka/publication.rb +129 -0
- data/skills/shaka/lib/shaka/publishing.rb +137 -0
- data/skills/shaka/lib/shaka/recommendation.rb +70 -0
- data/skills/shaka/lib/shaka/response_count.rb +20 -0
- data/skills/shaka/lib/shaka/usage.rb +229 -0
- data/skills/shaka/lib/shaka/work.rb +121 -0
- data/skills/shaka/scripts/shaka +89 -0
- metadata +70 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'response_count'
|
|
5
|
+
|
|
6
|
+
module Shaka
|
|
7
|
+
# Reads Claude Code session transcripts (tested with 2.1.270 and 2.1.272), keeping only usage metadata.
|
|
8
|
+
class ClaudeUsage
|
|
9
|
+
include ResponseCount
|
|
10
|
+
|
|
11
|
+
HOST = 'Claude Code'
|
|
12
|
+
NOTE = 'Anthropic input excludes cached input and cache writes; reasoning output is part of output.'
|
|
13
|
+
LATEST_SCOPE = 'latest turn of the session, including its subagents; earlier turns excluded'
|
|
14
|
+
|
|
15
|
+
attr_reader :responses, :versions, :gaps
|
|
16
|
+
|
|
17
|
+
def self.discover
|
|
18
|
+
identity = ENV.fetch('CLAUDE_CODE_SESSION_ID', nil)
|
|
19
|
+
return [] unless identity&.match?(/\A[0-9a-f-]{36}\z/)
|
|
20
|
+
|
|
21
|
+
home = ENV.fetch('CLAUDE_CONFIG_DIR', File.expand_path('~/.claude'))
|
|
22
|
+
sessions = Dir.glob(File.join(home, 'projects', '*', "#{identity}.jsonl"))
|
|
23
|
+
return [] unless sessions.one? && session_of(sessions.first) == identity
|
|
24
|
+
|
|
25
|
+
sessions + Dir.glob(File.join(File.dirname(sessions.first), identity, 'subagents', '*.jsonl'))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.session_of(file)
|
|
29
|
+
File.foreach(file, encoding: 'UTF-8') do |line|
|
|
30
|
+
record = JSON.parse(line)
|
|
31
|
+
return record['sessionId'] if record.is_a?(Hash) && record.key?('sessionId')
|
|
32
|
+
rescue JSON::ParserError, EncodingError
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
nil
|
|
36
|
+
rescue SystemCallError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method :session_of
|
|
41
|
+
|
|
42
|
+
def initialize(files, turns, all_turns: false)
|
|
43
|
+
@responses = {}
|
|
44
|
+
@versions = []
|
|
45
|
+
@gaps = []
|
|
46
|
+
sources = files.map { |file| read(file) }
|
|
47
|
+
records = sources.map(&:first).flat_map(&:values)
|
|
48
|
+
selected(records, wanted_turns(sources, turns), all_turns).each { |record| count(record) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Without explicit turns, every source uses the first source's latest turn.
|
|
54
|
+
def wanted_turns(sources, turns)
|
|
55
|
+
(turns.empty? ? [sources.dig(0, 1)] : turns).select { |turn| turn?(turn) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Every mode needs an identified turn, as in the Codex reader.
|
|
59
|
+
def selected(records, wanted, all_turns)
|
|
60
|
+
identified = records.select { |record| turn?(record['turn_id']) }
|
|
61
|
+
unreadable if all_turns && identified.size < records.size
|
|
62
|
+
all_turns ? identified : identified.select { |record| wanted.include?(record['turn_id']) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def turn?(turn)
|
|
66
|
+
turn.is_a?(String) && !turn.strip.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Streamed lines repeat a response; the last line carries its final usage.
|
|
70
|
+
def read(file)
|
|
71
|
+
records = {}
|
|
72
|
+
turn = nil
|
|
73
|
+
File.foreach(file, encoding: 'UTF-8') do |line|
|
|
74
|
+
record = parse(line)
|
|
75
|
+
turn = record['promptId'] if record['type'] == 'user'
|
|
76
|
+
records.merge!(response(record, turn)) if record['type'] == 'assistant'
|
|
77
|
+
end
|
|
78
|
+
[records, turn]
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
[unreadable, nil]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def response(record, turn)
|
|
84
|
+
message = record['message'].is_a?(Hash) ? record['message'] : {}
|
|
85
|
+
@versions << record['version'] if record['version'].is_a?(String)
|
|
86
|
+
{ message['id'] => { 'response_id' => message['id'], 'turn_id' => turn, 'timestamp' => record['timestamp'],
|
|
87
|
+
'configuration' => ['anthropic', 'UNKNOWN', message['model'], record['effort']],
|
|
88
|
+
'usage' => tokens(message['usage']) } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def tokens(usage)
|
|
92
|
+
return {} unless usage.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
details = usage['output_tokens_details']
|
|
95
|
+
{ 'input_tokens' => usage['input_tokens'], 'cached_input_tokens' => usage['cache_read_input_tokens'],
|
|
96
|
+
'output_tokens' => usage['output_tokens'], 'cache_write_input_tokens' => usage['cache_creation_input_tokens'],
|
|
97
|
+
'reasoning_output_tokens' => (details['thinking_tokens'] if details.is_a?(Hash)) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse(line)
|
|
101
|
+
record = JSON.parse(line)
|
|
102
|
+
record.is_a?(Hash) ? record : unreadable
|
|
103
|
+
rescue JSON::ParserError, EncodingError
|
|
104
|
+
unreadable
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def unreadable
|
|
108
|
+
@gaps << 'Unreadable or unidentifiable records'
|
|
109
|
+
{}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shaka
|
|
4
|
+
# Prices configured-model scenarios from disjoint per-response token categories.
|
|
5
|
+
class CostEstimate
|
|
6
|
+
VERIFIED = '2026-09-15'
|
|
7
|
+
THRESHOLD = 272_000
|
|
8
|
+
RATES = {
|
|
9
|
+
'gpt-5.6-terra' => { credits: %w[50 5 300], api: %w[2 0.2 12] },
|
|
10
|
+
'gpt-5.6-sol' => { credits: %w[100 10 500], api: %w[4 0.4 20] },
|
|
11
|
+
'gpt-6-astra' => { credits: %w[250 25 1250], api: %w[10 1 50] }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(responses)
|
|
15
|
+
@responses = responses
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def report
|
|
19
|
+
reasons = []
|
|
20
|
+
rows = @responses.group_by { |record| record['configuration'] }.map do |configuration, group|
|
|
21
|
+
row(configuration, group, reasons)
|
|
22
|
+
end.join("\n")
|
|
23
|
+
rows = '| UNKNOWN | UNKNOWN | UNKNOWN | UNKNOWN | UNKNOWN |' if rows.empty?
|
|
24
|
+
markdown(rows, reasons)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def row(configuration, group, reasons)
|
|
30
|
+
credits, credit_reason = total(group, :credits)
|
|
31
|
+
api, api_reason = total(group, :api)
|
|
32
|
+
reasons.concat([credit_reason, api_reason].compact)
|
|
33
|
+
provider, model, _, effort = configuration
|
|
34
|
+
"| #{[safe(provider), safe(model), safe(effort), show(credits, 'credits'), show(api, '$')].join(' | ')} |"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def markdown(rows, reasons)
|
|
38
|
+
<<~MARKDOWN
|
|
39
|
+
|
|
40
|
+
Cost estimates are PARTIAL configured-model scenarios for the selected native responses.
|
|
41
|
+
Actual charge: UNKNOWN (routed model, billing mode, service tier, account terms, and external work unavailable).
|
|
42
|
+
|
|
43
|
+
<details>
|
|
44
|
+
<summary>Cost scenarios</summary>
|
|
45
|
+
|
|
46
|
+
Standard Codex credit and Standard OpenAI API-equivalent rates verified #{VERIFIED};
|
|
47
|
+
historical rates and account-specific terms may differ. Effort has no price multiplier.
|
|
48
|
+
Cached input and reasoning output are subsets, not extra charges. API cache writes
|
|
49
|
+
are included in input; Codex credit cache-write pricing is unavailable.
|
|
50
|
+
The API scenario applies each request's 272K context threshold before summing.
|
|
51
|
+
|
|
52
|
+
| Provider | Configured model | Effort | Codex credits estimate | API-equivalent USD estimate |
|
|
53
|
+
| --- | --- | --- | ---: | ---: |
|
|
54
|
+
#{rows}
|
|
55
|
+
|
|
56
|
+
#{reasons.uniq.join('; ')}
|
|
57
|
+
Sources: [Codex credit rates](https://learn.chatgpt.com/docs/pricing#token-rates),
|
|
58
|
+
API prices for [Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra),
|
|
59
|
+
[Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol), and
|
|
60
|
+
[Astra](https://developers.openai.com/api/docs/models/gpt-6-astra),
|
|
61
|
+
[prompt-cache accounting](https://developers.openai.com/api/docs/guides/prompt-caching).
|
|
62
|
+
|
|
63
|
+
</details>
|
|
64
|
+
MARKDOWN
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def total(group, mode)
|
|
68
|
+
amounts = group.map { |record| price(record, mode) }
|
|
69
|
+
reason = amounts.map(&:last).compact.first
|
|
70
|
+
[reason ? nil : amounts.sum { |amount, _| amount }, reason]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def price(record, mode)
|
|
74
|
+
provider, model = record['configuration']
|
|
75
|
+
rate = RATES.dig(model, mode) if provider == 'openai'
|
|
76
|
+
return [nil, 'Unsupported provider or configured model'] unless rate
|
|
77
|
+
|
|
78
|
+
tokens, reason = categories(record['usage'])
|
|
79
|
+
return [nil, reason] if reason
|
|
80
|
+
return [nil, 'Credit cache-write rate UNKNOWN'] if mode == :credits && tokens[2].positive?
|
|
81
|
+
|
|
82
|
+
[bill(tokens, rate, mode) / 1_000_000, nil]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def categories(usage)
|
|
86
|
+
return [nil, 'Incomplete billable token categories'] unless usage.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
tokens = %w[input_tokens cached_input_tokens cache_write_input_tokens output_tokens].map { |field| usage[field] }
|
|
89
|
+
return [nil, 'Incomplete billable token categories'] unless valid_counters?(tokens)
|
|
90
|
+
|
|
91
|
+
input, cached, writes, output = tokens
|
|
92
|
+
reasoning = usage['reasoning_output_tokens']
|
|
93
|
+
return [nil, 'Inconsistent token subsets'] if cached + writes > input || invalid_reasoning?(reasoning, output)
|
|
94
|
+
|
|
95
|
+
[tokens, nil]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def bill(tokens, rate, mode)
|
|
99
|
+
large = mode == :api && tokens[0] > THRESHOLD
|
|
100
|
+
(input_bill(tokens, rate, mode) * (large ? 2 : 1)) +
|
|
101
|
+
(tokens[3] * Rational(rate[2]) * (large ? Rational(3, 2) : 1))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def input_bill(tokens, rate, mode)
|
|
105
|
+
input, cached, writes = tokens
|
|
106
|
+
input_rate, cached_rate = rate.first(2).map { |value| Rational(value) }
|
|
107
|
+
amount = ((input - cached - writes) * input_rate) + (cached * cached_rate)
|
|
108
|
+
mode == :api ? amount + (writes * input_rate * Rational(5, 4)) : amount
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def valid_counters?(tokens)
|
|
112
|
+
tokens.all? { |value| value.is_a?(Integer) && value >= 0 }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def invalid_reasoning?(reasoning, output)
|
|
116
|
+
reasoning.is_a?(Integer) && (reasoning.negative? || reasoning > output)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def show(amount, unit)
|
|
120
|
+
return 'UNKNOWN' unless amount
|
|
121
|
+
|
|
122
|
+
formatted = format('%.6f', amount)
|
|
123
|
+
unit == '$' ? "#{unit}#{formatted}" : "#{formatted} #{unit}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def safe(value)
|
|
127
|
+
value.is_a?(String) && value.match?(/\A[a-zA-Z0-9][a-zA-Z0-9._:-]{0,79}\z/) ? value : 'UNKNOWN'
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
require_relative 'publishing'
|
|
7
|
+
|
|
8
|
+
module Shaka
|
|
9
|
+
# The native pull-request evidence a publication decision depends on.
|
|
10
|
+
SNAPSHOT_QUERY = <<~GRAPHQL
|
|
11
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
12
|
+
repository(owner: $owner, name: $name) {
|
|
13
|
+
pullRequest(number: $number) {
|
|
14
|
+
id number url state isDraft headRefOid baseRefName
|
|
15
|
+
mergeStateStatus reviewDecision viewerCanMergeAsAdmin
|
|
16
|
+
isInMergeQueue isMergeQueueEnabled autoMergeRequest { enabledAt }
|
|
17
|
+
headRepository { nameWithOwner } baseRepository { nameWithOwner }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
GRAPHQL
|
|
22
|
+
|
|
23
|
+
# Reads native PR evidence and publishes reviews bound to its current commit.
|
|
24
|
+
class GitHub
|
|
25
|
+
include Publishing
|
|
26
|
+
|
|
27
|
+
def initialize(repository, number, runner: nil)
|
|
28
|
+
unless repository.is_a?(String) && repository.ascii_only? &&
|
|
29
|
+
repository.match?(%r{\A[\w-]+/(?!\.{1,2}\z)[\w.-]+\z})
|
|
30
|
+
raise Error, 'Expected a GitHub repository in OWNER/REPO form.'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@repository = repository
|
|
34
|
+
@number = positive_integer(number)
|
|
35
|
+
@runner = runner || ->(argv, stdin_data:) { Open3.capture3(*argv, stdin_data: stdin_data) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def snapshot
|
|
39
|
+
owner, name = @repository.split('/')
|
|
40
|
+
repository = graphql(SNAPSHOT_QUERY, owner: owner, name: name, number: @number)['repository']
|
|
41
|
+
result = repository['pullRequest'] if repository.is_a?(Hash)
|
|
42
|
+
raise Error, 'GitHub did not return the requested pull request.' unless result.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def required_checks
|
|
48
|
+
result = execute(['gh', 'pr', 'checks', @number.to_s, '--repo', @repository,
|
|
49
|
+
'--required', '--json', 'name,state,bucket,link'], accepted: [0, 1, 8])
|
|
50
|
+
raise Error, 'GitHub required checks response must be an array.' unless result.is_a?(Array)
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
rescue Error
|
|
54
|
+
raise Error, 'Required-check evidence is unavailable; confirm native required checks and GitHub access.'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def review(id)
|
|
58
|
+
api("#{reviews_path}/#{positive_integer(id)}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def walkthrough(head:, body:)
|
|
62
|
+
body = utf8(body)
|
|
63
|
+
raise Error, 'Walkthrough body must be nonempty.' if body.strip.empty?
|
|
64
|
+
|
|
65
|
+
verify_head(head)
|
|
66
|
+
verify_rendering(body)
|
|
67
|
+
created = api(reviews_path, method: 'POST', fields: { event: 'COMMENT', commit_id: head, body: body })
|
|
68
|
+
published = review(created['id'])
|
|
69
|
+
verify_review(published, created['id'], head, body)
|
|
70
|
+
verify_head(head, review_id: created['id'])
|
|
71
|
+
published
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def api(path, method: 'GET', fields: {})
|
|
75
|
+
result = execute(['gh', 'api', path, '--method', method, '--input', '-'], input: JSON.generate(fields))
|
|
76
|
+
raise Error, 'GitHub API response must be an object.' unless result.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def graphql(query, variables = {})
|
|
82
|
+
response = api('graphql', method: 'POST', fields: { query: query, variables: variables })
|
|
83
|
+
raise Error, 'GraphQL failed or returned missing data.' if response['errors'] || !response['data'].is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
response['data']
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def positive_integer(value)
|
|
91
|
+
unless value.to_s.ascii_only? && value.to_s.match?(/\A[1-9]\d*\z/)
|
|
92
|
+
raise Error, 'Expected a positive integer identifier.'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
value.to_i
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reviews_path
|
|
99
|
+
"repos/#{@repository}/pulls/#{@number}/reviews"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def verify_head(head, review_id: nil)
|
|
103
|
+
raise Error, 'Expected a full commit SHA.' unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
|
|
104
|
+
|
|
105
|
+
pr = snapshot
|
|
106
|
+
return if pr['state'] == 'OPEN' && pr['headRefOid'] == head
|
|
107
|
+
|
|
108
|
+
detail = review_id ? " Review #{review_id} was created; inspect the PR before retrying." : ''
|
|
109
|
+
raise Error, "Pull request is not open at the expected head.#{detail}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def verify_review(review, id, head, body)
|
|
113
|
+
return if review.values_at('id', 'state', 'commit_id', 'body') == [id, 'COMMENTED', head, body]
|
|
114
|
+
|
|
115
|
+
raise Error, 'Published walkthrough review did not match its commit, body, or COMMENT state.'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def execute(argv, input: '', accepted: [0]) = parse_json(capture(argv, input: input, accepted: accepted))
|
|
119
|
+
|
|
120
|
+
def capture(argv, input: '', accepted: [0])
|
|
121
|
+
stdout, _stderr, status = @runner.call(argv, stdin_data: input)
|
|
122
|
+
unless accepted.include?(status.exitstatus)
|
|
123
|
+
raise Error, "gh #{argv[1, 2].join(' ')} failed (exit #{status.exitstatus})."
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
utf8(stdout)
|
|
127
|
+
rescue Errno::ENOENT
|
|
128
|
+
raise Error, 'GitHub CLI is unavailable; install gh and authenticate.'
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_json(output)
|
|
132
|
+
JSON.parse(utf8(output))
|
|
133
|
+
rescue JSON::ParserError
|
|
134
|
+
raise Error, 'GitHub returned invalid JSON.'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def utf8(value)
|
|
138
|
+
raise Error, 'Expected UTF-8 text.' unless value.is_a?(String)
|
|
139
|
+
|
|
140
|
+
text = value.dup.force_encoding(Encoding::UTF_8)
|
|
141
|
+
raise Error, 'Invalid UTF-8 text.' unless text.valid_encoding?
|
|
142
|
+
|
|
143
|
+
text
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error'
|
|
4
|
+
|
|
5
|
+
module Shaka
|
|
6
|
+
# Applies native GitHub gates; the calling skill must establish merge authority.
|
|
7
|
+
class Merge
|
|
8
|
+
MUTATION = <<~GRAPHQL
|
|
9
|
+
mutation($id: ID!, $head: GitObjectID!) {
|
|
10
|
+
mergePullRequest(input: {pullRequestId: $id, expectedHeadOid: $head, mergeMethod: SQUASH}) {
|
|
11
|
+
pullRequest { state headRefOid merged mergeCommit { oid } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
GRAPHQL
|
|
15
|
+
|
|
16
|
+
def initialize(github)
|
|
17
|
+
@github = github
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(head:, walkthrough:)
|
|
21
|
+
raise Error, 'Expected a full commit SHA' unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
|
|
22
|
+
|
|
23
|
+
verify_snapshot(@github.snapshot, head)
|
|
24
|
+
verify_checks(@github.required_checks)
|
|
25
|
+
verify_walkthrough(@github.review(walkthrough), head, walkthrough)
|
|
26
|
+
current = @github.snapshot
|
|
27
|
+
verify_snapshot(current, head)
|
|
28
|
+
submit(current.fetch('id'), head)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def verify_snapshot(pull, head)
|
|
34
|
+
raise Error, 'PR head changed; refresh verification and walkthrough' unless pull['headRefOid'] == head
|
|
35
|
+
raise Error, 'PR must be open and not a draft' unless pull['state'] == 'OPEN' && pull['isDraft'] == false
|
|
36
|
+
raise Error, 'GitHub PR identity is missing' unless pull['id'].is_a?(String) && !pull['id'].empty?
|
|
37
|
+
|
|
38
|
+
verify_submission_mode(pull)
|
|
39
|
+
verify_native_state(pull)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def verify_submission_mode(pull)
|
|
43
|
+
raise Error, 'Native protection must be enforced for this actor' unless pull['viewerCanMergeAsAdmin'] == false
|
|
44
|
+
unless pull['isMergeQueueEnabled'] == false && pull['isInMergeQueue'] == false
|
|
45
|
+
raise Error, 'Merge queues are unsupported by this pilot'
|
|
46
|
+
end
|
|
47
|
+
return if pull.key?('autoMergeRequest') && pull['autoMergeRequest'].nil?
|
|
48
|
+
|
|
49
|
+
raise Error, 'Existing or unknown delayed auto-merge blocks immediate merge'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def verify_native_state(pull)
|
|
53
|
+
unless pull['mergeStateStatus'] == 'CLEAN'
|
|
54
|
+
raise Error, "GitHub merge state is not CLEAN: #{pull['mergeStateStatus'].inspect}"
|
|
55
|
+
end
|
|
56
|
+
return if pull.key?('reviewDecision') && [nil, 'APPROVED'].include?(pull['reviewDecision'])
|
|
57
|
+
|
|
58
|
+
raise Error, 'Required reviews are not satisfied or their state is unknown'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def verify_checks(checks)
|
|
62
|
+
unless checks.is_a?(Array) && !checks.empty?
|
|
63
|
+
raise Error, 'No observable required checks; native readiness is unknown'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
checks.each do |check|
|
|
67
|
+
next if passing_check?(check)
|
|
68
|
+
|
|
69
|
+
raise Error, "Required check is not passing or is malformed: #{check.inspect}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def passing_check?(check)
|
|
74
|
+
return false unless check.is_a?(Hash) && check['name'].is_a?(String) && !check['name'].strip.empty?
|
|
75
|
+
|
|
76
|
+
case check['state']
|
|
77
|
+
when 'SUCCESS' then check['bucket'] == 'pass'
|
|
78
|
+
when 'NEUTRAL', 'SKIPPED' then check['bucket'] == 'skipping'
|
|
79
|
+
else false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def verify_walkthrough(review, head, id)
|
|
84
|
+
unless review.is_a?(Hash) && review['id'].to_s == id.to_s && review['commit_id'] == head
|
|
85
|
+
raise Error, 'Walkthrough must identify a review on this PR at the expected head'
|
|
86
|
+
end
|
|
87
|
+
return if review['state'] == 'COMMENTED' && review['body'].is_a?(String) && !review['body'].strip.empty?
|
|
88
|
+
|
|
89
|
+
raise Error, 'Walkthrough must be a submitted COMMENT review with a nonempty body'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def submit(id, head)
|
|
93
|
+
result = @github.graphql(MUTATION, { 'id' => id, 'head' => head })
|
|
94
|
+
payload = result['mergePullRequest']
|
|
95
|
+
pr = payload['pullRequest'] if payload.is_a?(Hash)
|
|
96
|
+
unless pr.is_a?(Hash) && pr['merged'] == true && pr['state'] == 'MERGED' && pr['headRefOid'] == head
|
|
97
|
+
raise Error, 'GitHub did not confirm merging the expected head'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
pr
|
|
101
|
+
rescue Error => e
|
|
102
|
+
raise Error, "#{e.message}; inspect live PR state before retrying a merge"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error'
|
|
4
|
+
|
|
5
|
+
module Shaka
|
|
6
|
+
# Checks supplied text for the mechanical failures models reproduce by hand.
|
|
7
|
+
module PublicationText
|
|
8
|
+
ESCAPE = /\\[nrt]/
|
|
9
|
+
IDENTITY_FIELDS = %w[agent provider model effort].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def required(value, field)
|
|
14
|
+
raise Error, "Publication #{field} must be nonempty text." unless value.is_a?(String) && !value.strip.empty?
|
|
15
|
+
|
|
16
|
+
checked(value, field)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fenced blocks and code spans hold intentional examples; only prose is checked.
|
|
20
|
+
def prose(text)
|
|
21
|
+
text.gsub(/^~~~.*?^~~~/m, '').gsub(/```.*?```/m, '').gsub(/(`+)[^`]*\1/, '')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A summary is interpolated into raw HTML, so it must not be able to close its own tag.
|
|
25
|
+
def summary_text(value, field)
|
|
26
|
+
single_line(value, field).gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def list(value, field)
|
|
30
|
+
return [] if value.nil?
|
|
31
|
+
raise Error, "Publication #{field} must be a list." unless value.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def single_line(value, field)
|
|
37
|
+
text = required(value, field)
|
|
38
|
+
raise Error, "Publication #{field} must be a single line." if text.match?(/[\r\n]/)
|
|
39
|
+
|
|
40
|
+
text
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def checked(value, field)
|
|
44
|
+
return value unless prose(value).match?(ESCAPE)
|
|
45
|
+
|
|
46
|
+
raise Error, "Publication #{field} contains a literal escape sequence; supply real line breaks."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def identity(value)
|
|
50
|
+
raise Error, 'Publication identity must be supplied.' unless value.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
fields = IDENTITY_FIELDS.map do |field|
|
|
53
|
+
text = value[field]
|
|
54
|
+
text.is_a?(String) && !text.strip.empty? ? single_line(text.strip, "identity #{field}") : 'UNKNOWN'
|
|
55
|
+
end
|
|
56
|
+
"🤖 #{fields.join(' · ')}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Renders the publication surfaces so headings, spacing, tables and details are Ruby's.
|
|
61
|
+
class Publication
|
|
62
|
+
def self.description(content) = new(content).render(%i[sections table details])
|
|
63
|
+
def self.comment(content) = new(content).render([])
|
|
64
|
+
def self.walkthrough(content) = new(content).render(%i[sections table details revision])
|
|
65
|
+
|
|
66
|
+
def initialize(content)
|
|
67
|
+
raise Error, 'Publication content must be an object.' unless content.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
@content = content
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render(parts)
|
|
73
|
+
blocks = [PublicationText.identity(@content['identity']),
|
|
74
|
+
PublicationText.required(@content['summary'], 'summary')]
|
|
75
|
+
parts.each { |part| blocks.concat(send(part)) }
|
|
76
|
+
"#{blocks.join("\n\n")}\n"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def sections
|
|
82
|
+
PublicationText.list(@content['sections'], 'sections').map do |section|
|
|
83
|
+
heading = PublicationText.single_line(section['heading'], 'section heading')
|
|
84
|
+
"## #{heading}\n\n#{PublicationText.required(section['body'], "section #{heading}")}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def table
|
|
89
|
+
spec = @content['table']
|
|
90
|
+
return [] if spec.nil?
|
|
91
|
+
|
|
92
|
+
columns = table_columns(spec)
|
|
93
|
+
rows = PublicationText.list(spec['rows'], 'table rows').map { |row| table_row(row, columns.size) }
|
|
94
|
+
[[table_line(columns), table_line(['---'] * columns.size), *rows].join("\n")]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def table_columns(spec)
|
|
98
|
+
columns = spec.is_a?(Hash) ? PublicationText.list(spec['columns'], 'table columns') : []
|
|
99
|
+
raise Error, 'Publication table must define at least one column.' if columns.empty?
|
|
100
|
+
|
|
101
|
+
columns.map { |column| PublicationText.single_line(column, 'table column') }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def table_row(row, width)
|
|
105
|
+
cells = PublicationText.list(row, 'table row')
|
|
106
|
+
raise Error, "Publication table row has #{cells.size} cells; #{width} columns are defined." if cells.size != width
|
|
107
|
+
|
|
108
|
+
table_line(cells.map { |cell| PublicationText.single_line(cell.to_s, 'table cell') })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Escaping pipes keeps a cell from silently adding a column.
|
|
112
|
+
def table_line(cells) = "| #{cells.map { |cell| cell.gsub('|', '\\|') }.join(' | ')} |"
|
|
113
|
+
|
|
114
|
+
def details
|
|
115
|
+
PublicationText.list(@content['details'], 'details').map do |detail|
|
|
116
|
+
summary = PublicationText.summary_text(detail['summary'], 'details summary')
|
|
117
|
+
body = PublicationText.required(detail['body'], "details #{summary}")
|
|
118
|
+
"<details>\n<summary>#{summary}</summary>\n\n#{body}\n\n</details>"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def revision
|
|
123
|
+
head = @content['head']
|
|
124
|
+
raise Error, 'Walkthrough requires the full commit SHA it explains.' unless head.to_s.match?(/\A[0-9a-f]{40}\z/)
|
|
125
|
+
|
|
126
|
+
["_Walkthrough for commit `#{head}`. This is a COMMENT, not an approval._"]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|