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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shaka
4
+ # An actionable failure at the workflow boundary.
5
+ class Error < StandardError; end
6
+ 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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
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