spurline-core 0.3.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/CLAUDE.md +11 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/CLAUDE.md +11 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/CLAUDE.md +11 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/CLAUDE.md +18 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/CLAUDE.md +11 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/CLAUDE.md +12 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/CLAUDE.md +12 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- metadata +333 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Spurline
|
|
7
|
+
module Cartographer
|
|
8
|
+
# Immutable, serializable analysis output for a repository.
|
|
9
|
+
class RepoProfile
|
|
10
|
+
CURRENT_VERSION = "1.0"
|
|
11
|
+
|
|
12
|
+
attr_reader :version, :analyzed_at, :repo_path,
|
|
13
|
+
:languages, :frameworks, :ruby_version, :node_version,
|
|
14
|
+
:ci, :entry_points, :environment_vars_required,
|
|
15
|
+
:security_findings, :confidence, :metadata
|
|
16
|
+
|
|
17
|
+
def initialize(**attrs)
|
|
18
|
+
@version = CURRENT_VERSION
|
|
19
|
+
@analyzed_at = normalize_time(attrs.fetch(:analyzed_at, Time.now.utc.iso8601))
|
|
20
|
+
@repo_path = attrs.fetch(:repo_path)
|
|
21
|
+
@languages = deep_copy(attrs.fetch(:languages, {}))
|
|
22
|
+
@frameworks = deep_copy(attrs.fetch(:frameworks, {}))
|
|
23
|
+
@ruby_version = attrs.fetch(:ruby_version, nil)
|
|
24
|
+
@node_version = attrs.fetch(:node_version, nil)
|
|
25
|
+
@ci = deep_copy(attrs.fetch(:ci, {}))
|
|
26
|
+
@entry_points = deep_copy(attrs.fetch(:entry_points, {}))
|
|
27
|
+
@environment_vars_required = deep_copy(attrs.fetch(:environment_vars_required, []))
|
|
28
|
+
@security_findings = deep_copy(attrs.fetch(:security_findings, []))
|
|
29
|
+
@confidence = deep_copy(attrs.fetch(:confidence, {}))
|
|
30
|
+
@metadata = deep_copy(attrs.fetch(:metadata, {}))
|
|
31
|
+
|
|
32
|
+
deep_freeze(@languages)
|
|
33
|
+
deep_freeze(@frameworks)
|
|
34
|
+
deep_freeze(@ci)
|
|
35
|
+
deep_freeze(@entry_points)
|
|
36
|
+
deep_freeze(@environment_vars_required)
|
|
37
|
+
deep_freeze(@security_findings)
|
|
38
|
+
deep_freeze(@confidence)
|
|
39
|
+
deep_freeze(@metadata)
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
{
|
|
45
|
+
version: version,
|
|
46
|
+
analyzed_at: analyzed_at,
|
|
47
|
+
repo_path: repo_path,
|
|
48
|
+
languages: deep_copy(languages),
|
|
49
|
+
frameworks: deep_copy(frameworks),
|
|
50
|
+
ruby_version: ruby_version,
|
|
51
|
+
node_version: node_version,
|
|
52
|
+
ci: deep_copy(ci),
|
|
53
|
+
entry_points: deep_copy(entry_points),
|
|
54
|
+
environment_vars_required: deep_copy(environment_vars_required),
|
|
55
|
+
security_findings: deep_copy(security_findings),
|
|
56
|
+
confidence: deep_copy(confidence),
|
|
57
|
+
metadata: deep_copy(metadata),
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.from_h(hash)
|
|
62
|
+
data = deep_symbolize(hash || {})
|
|
63
|
+
new(
|
|
64
|
+
analyzed_at: data[:analyzed_at],
|
|
65
|
+
repo_path: data.fetch(:repo_path),
|
|
66
|
+
languages: data[:languages] || {},
|
|
67
|
+
frameworks: data[:frameworks] || {},
|
|
68
|
+
ruby_version: data[:ruby_version],
|
|
69
|
+
node_version: data[:node_version],
|
|
70
|
+
ci: data[:ci] || {},
|
|
71
|
+
entry_points: data[:entry_points] || {},
|
|
72
|
+
environment_vars_required: data[:environment_vars_required] || [],
|
|
73
|
+
security_findings: data[:security_findings] || [],
|
|
74
|
+
confidence: data[:confidence] || {},
|
|
75
|
+
metadata: data[:metadata] || {}
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_json(*)
|
|
80
|
+
JSON.generate(to_h)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def secure?
|
|
84
|
+
security_findings.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def normalize_time(value)
|
|
90
|
+
return value.utc.iso8601 if value.respond_to?(:utc) && value.respond_to?(:iso8601)
|
|
91
|
+
|
|
92
|
+
value.to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def deep_copy(value)
|
|
96
|
+
case value
|
|
97
|
+
when Hash
|
|
98
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
99
|
+
copy[key] = deep_copy(item)
|
|
100
|
+
end
|
|
101
|
+
when Array
|
|
102
|
+
value.map { |item| deep_copy(item) }
|
|
103
|
+
else
|
|
104
|
+
value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def deep_freeze(value)
|
|
109
|
+
case value
|
|
110
|
+
when Hash
|
|
111
|
+
value.each do |key, item|
|
|
112
|
+
deep_freeze(key)
|
|
113
|
+
deep_freeze(item)
|
|
114
|
+
end
|
|
115
|
+
when Array
|
|
116
|
+
value.each { |item| deep_freeze(item) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
value.freeze
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
class << self
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def deep_symbolize(value)
|
|
126
|
+
case value
|
|
127
|
+
when Hash
|
|
128
|
+
value.each_with_object({}) do |(key, item), hash|
|
|
129
|
+
hash[key.to_sym] = deep_symbolize(item)
|
|
130
|
+
end
|
|
131
|
+
when Array
|
|
132
|
+
value.map { |item| deep_symbolize(item) }
|
|
133
|
+
else
|
|
134
|
+
value
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Cartographer
|
|
5
|
+
class Runner
|
|
6
|
+
ANALYZERS = [
|
|
7
|
+
Analyzers::FileSignatures,
|
|
8
|
+
Analyzers::Manifests,
|
|
9
|
+
Analyzers::CIConfig,
|
|
10
|
+
Analyzers::Dotfiles,
|
|
11
|
+
Analyzers::EntryPoints,
|
|
12
|
+
Analyzers::SecurityScan,
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# ASYNC-READY:
|
|
16
|
+
def analyze(repo_path:, scheduler: Spurline::Adapters::Scheduler::Sync.new)
|
|
17
|
+
expanded_path = File.expand_path(repo_path)
|
|
18
|
+
validate_path!(expanded_path)
|
|
19
|
+
|
|
20
|
+
results = {}
|
|
21
|
+
confidences = {}
|
|
22
|
+
active_scheduler = scheduler.is_a?(Class) ? scheduler.new : scheduler
|
|
23
|
+
|
|
24
|
+
ANALYZERS.each do |klass|
|
|
25
|
+
analyzer = klass.new(repo_path: expanded_path)
|
|
26
|
+
layer_result = active_scheduler.run { analyzer.analyze }
|
|
27
|
+
|
|
28
|
+
unless layer_result.is_a?(Hash)
|
|
29
|
+
raise Spurline::AnalyzerError,
|
|
30
|
+
"#{klass.name} returned #{layer_result.class} instead of Hash"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
results = deep_merge(results, layer_result)
|
|
34
|
+
confidences[analyzer_key(klass)] = analyzer.confidence
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
confidences[analyzer_key(klass)] = 0.0
|
|
37
|
+
results[:metadata] ||= {}
|
|
38
|
+
(results[:metadata][:analyzer_errors] ||= []) << {
|
|
39
|
+
analyzer: klass.name,
|
|
40
|
+
error: e.message,
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
results = deep_merge(results, confidence: build_confidence(confidences))
|
|
45
|
+
|
|
46
|
+
RepoProfile.new(repo_path: expanded_path, **results)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validate_path!(path)
|
|
52
|
+
return if File.directory?(path)
|
|
53
|
+
|
|
54
|
+
raise Spurline::CartographerAccessError,
|
|
55
|
+
"Repository path '#{path}' does not exist or is not a directory. " \
|
|
56
|
+
"Provide an absolute path to a valid repository."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def analyzer_key(klass)
|
|
60
|
+
name = klass.name || "AnonymousAnalyzer#{klass.object_id}"
|
|
61
|
+
name = name.split("::").last
|
|
62
|
+
name = name.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
|
|
63
|
+
name = name.gsub(/([a-z\\d])([A-Z])/, "\\1_\\2")
|
|
64
|
+
name.downcase.to_sym
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_confidence(layer_confidences)
|
|
68
|
+
scores = layer_confidences.values
|
|
69
|
+
{
|
|
70
|
+
overall: scores.empty? ? 0.0 : (scores.sum / scores.size).round(2),
|
|
71
|
+
per_layer: layer_confidences,
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deep_merge(left, right)
|
|
76
|
+
left.merge(right) do |_key, left_value, right_value|
|
|
77
|
+
if left_value.is_a?(Hash) && right_value.is_a?(Hash)
|
|
78
|
+
deep_merge(left_value, right_value)
|
|
79
|
+
elsif left_value.is_a?(Array) && right_value.is_a?(Array)
|
|
80
|
+
(left_value + right_value).uniq
|
|
81
|
+
else
|
|
82
|
+
right_value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Channels
|
|
5
|
+
# Abstract interface for channel adapters. Each channel parses events from
|
|
6
|
+
# a specific external source and resolves session affinity.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses must implement:
|
|
9
|
+
# #channel_name - Symbol identifying this channel
|
|
10
|
+
# #route(payload) - Parse payload, resolve session, return Event or nil
|
|
11
|
+
# #supported_events - Array of event type symbols this channel handles
|
|
12
|
+
class Base
|
|
13
|
+
# Symbol identifying this channel (e.g., :github, :slack).
|
|
14
|
+
def channel_name
|
|
15
|
+
raise NotImplementedError, "#{self.class.name} must implement #channel_name"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Parses a raw payload hash and returns a routed Event, or nil if the
|
|
19
|
+
# payload is not recognized or not relevant.
|
|
20
|
+
#
|
|
21
|
+
# @param payload [Hash] The raw event payload (e.g., parsed webhook JSON)
|
|
22
|
+
# @param headers [Hash] Optional HTTP headers for signature verification
|
|
23
|
+
# @return [Spurline::Channels::Event, nil]
|
|
24
|
+
# ASYNC-READY:
|
|
25
|
+
def route(payload, headers: {})
|
|
26
|
+
raise NotImplementedError, "#{self.class.name} must implement #route"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the event types this channel can handle.
|
|
30
|
+
# @return [Array<Symbol>]
|
|
31
|
+
def supported_events
|
|
32
|
+
raise NotImplementedError, "#{self.class.name} must implement #supported_events"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Whether this channel handles the given event type.
|
|
36
|
+
def handles?(event_type)
|
|
37
|
+
supported_events.include?(event_type.to_sym)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Channels
|
|
7
|
+
# Immutable value object representing an external event routed through a channel.
|
|
8
|
+
# Events are the universal internal representation regardless of which channel
|
|
9
|
+
# produced them. Frozen on creation.
|
|
10
|
+
#
|
|
11
|
+
# Attributes:
|
|
12
|
+
# channel - Symbol identifying the source channel (e.g., :github)
|
|
13
|
+
# event_type - Symbol for the event kind (e.g., :issue_comment, :pr_review)
|
|
14
|
+
# payload - Hash of parsed event data (channel-specific)
|
|
15
|
+
# trust - Symbol trust level (default :external)
|
|
16
|
+
# session_id - String session ID if routing resolved, nil otherwise
|
|
17
|
+
# received_at - Time the event was received
|
|
18
|
+
class Event
|
|
19
|
+
attr_reader :channel, :event_type, :payload, :trust, :session_id, :received_at
|
|
20
|
+
|
|
21
|
+
def initialize(channel:, event_type:, payload:, trust: :external, session_id: nil, received_at: nil)
|
|
22
|
+
validate_channel!(channel)
|
|
23
|
+
validate_event_type!(event_type)
|
|
24
|
+
validate_payload!(payload)
|
|
25
|
+
validate_trust!(trust)
|
|
26
|
+
|
|
27
|
+
@channel = channel.to_sym
|
|
28
|
+
@event_type = event_type.to_sym
|
|
29
|
+
@payload = deep_freeze(payload)
|
|
30
|
+
@trust = trust.to_sym
|
|
31
|
+
@session_id = session_id&.to_s&.freeze
|
|
32
|
+
@received_at = (received_at || Time.now).freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether this event was matched to a specific session.
|
|
37
|
+
def routed?
|
|
38
|
+
!@session_id.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Serializes the event to a plain hash suitable for JSON serialization.
|
|
42
|
+
def to_h
|
|
43
|
+
{
|
|
44
|
+
channel: @channel,
|
|
45
|
+
event_type: @event_type,
|
|
46
|
+
payload: unfreeze_hash(@payload),
|
|
47
|
+
trust: @trust,
|
|
48
|
+
session_id: @session_id,
|
|
49
|
+
received_at: @received_at.iso8601(6),
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Reconstructs an Event from a hash (e.g., from JSON deserialization).
|
|
54
|
+
def self.from_h(hash)
|
|
55
|
+
h = symbolize_keys(hash)
|
|
56
|
+
new(
|
|
57
|
+
channel: h[:channel],
|
|
58
|
+
event_type: h[:event_type],
|
|
59
|
+
payload: symbolize_keys(h[:payload] || {}),
|
|
60
|
+
trust: h[:trust] || :external,
|
|
61
|
+
session_id: h[:session_id],
|
|
62
|
+
received_at: h[:received_at] ? Time.parse(h[:received_at].to_s) : nil
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(Event) &&
|
|
68
|
+
channel == other.channel &&
|
|
69
|
+
event_type == other.event_type &&
|
|
70
|
+
payload == other.payload &&
|
|
71
|
+
trust == other.trust &&
|
|
72
|
+
session_id == other.session_id
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inspect
|
|
76
|
+
"#<Spurline::Channels::Event channel=#{channel} type=#{event_type} " \
|
|
77
|
+
"session=#{session_id || 'unrouted'} trust=#{trust}>"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def validate_channel!(channel)
|
|
83
|
+
raise ArgumentError, "channel must be a Symbol or String" unless channel.respond_to?(:to_sym)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_event_type!(event_type)
|
|
87
|
+
raise ArgumentError, "event_type must be a Symbol or String" unless event_type.respond_to?(:to_sym)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_payload!(payload)
|
|
91
|
+
raise ArgumentError, "payload must be a Hash, got #{payload.class}" unless payload.is_a?(Hash)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_trust!(trust)
|
|
95
|
+
level = trust.to_sym
|
|
96
|
+
unless Spurline::Security::Content::TRUST_LEVELS.include?(level)
|
|
97
|
+
raise Spurline::ConfigurationError,
|
|
98
|
+
"Invalid trust level for channel event: #{trust.inspect}. " \
|
|
99
|
+
"Must be one of: #{Spurline::Security::Content::TRUST_LEVELS.inspect}."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def deep_freeze(obj)
|
|
104
|
+
case obj
|
|
105
|
+
when Hash
|
|
106
|
+
obj.each_with_object({}) { |(k, v), h| h[k.freeze] = deep_freeze(v) }.freeze
|
|
107
|
+
when Array
|
|
108
|
+
obj.map { |v| deep_freeze(v) }.freeze
|
|
109
|
+
when String
|
|
110
|
+
obj.dup.freeze
|
|
111
|
+
else
|
|
112
|
+
obj.freeze
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unfreeze_hash(obj)
|
|
117
|
+
case obj
|
|
118
|
+
when Hash
|
|
119
|
+
obj.each_with_object({}) { |(k, v), h| h[k] = unfreeze_hash(v) }
|
|
120
|
+
when Array
|
|
121
|
+
obj.map { |v| unfreeze_hash(v) }
|
|
122
|
+
else
|
|
123
|
+
obj
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.symbolize_keys(hash)
|
|
128
|
+
return {} unless hash.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
hash.each_with_object({}) do |(k, v), h|
|
|
131
|
+
h[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Channels
|
|
5
|
+
# GitHub webhook channel. Parses issue_comment, pull_request_review_comment,
|
|
6
|
+
# and pull_request_review events. Routes to sessions whose metadata contains
|
|
7
|
+
# a matching channel_context.
|
|
8
|
+
#
|
|
9
|
+
# Session affinity is resolved by matching:
|
|
10
|
+
# session.metadata[:channel_context] == { channel: :github, identifier: "owner/repo#123" }
|
|
11
|
+
#
|
|
12
|
+
# All GitHub webhook data enters at trust level :external.
|
|
13
|
+
class GitHub < Base
|
|
14
|
+
SUPPORTED_EVENTS = %i[
|
|
15
|
+
issue_comment
|
|
16
|
+
pull_request_review_comment
|
|
17
|
+
pull_request_review
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Maps GitHub webhook X-GitHub-Event header values to internal event types.
|
|
21
|
+
EVENT_MAP = {
|
|
22
|
+
"issue_comment" => :issue_comment,
|
|
23
|
+
"pull_request_review_comment" => :pull_request_review_comment,
|
|
24
|
+
"pull_request_review" => :pull_request_review,
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :store
|
|
28
|
+
|
|
29
|
+
def initialize(store:)
|
|
30
|
+
@store = store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def channel_name
|
|
34
|
+
:github
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def supported_events
|
|
38
|
+
SUPPORTED_EVENTS
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Parses a GitHub webhook payload and routes to a matching session.
|
|
42
|
+
#
|
|
43
|
+
# @param payload [Hash] Parsed JSON body of the webhook
|
|
44
|
+
# @param headers [Hash] HTTP headers, expects "X-GitHub-Event" key
|
|
45
|
+
# @return [Spurline::Channels::Event, nil]
|
|
46
|
+
# ASYNC-READY:
|
|
47
|
+
def route(payload, headers: {})
|
|
48
|
+
event_header = headers["X-GitHub-Event"] || headers["x-github-event"]
|
|
49
|
+
return nil unless event_header
|
|
50
|
+
|
|
51
|
+
event_type = EVENT_MAP[event_header]
|
|
52
|
+
return nil unless event_type
|
|
53
|
+
|
|
54
|
+
action = payload_value(payload, :action)
|
|
55
|
+
return nil unless actionable?(event_type, action)
|
|
56
|
+
|
|
57
|
+
parsed = parse_event(event_type, payload)
|
|
58
|
+
return nil unless parsed
|
|
59
|
+
|
|
60
|
+
identifier = build_identifier(parsed)
|
|
61
|
+
session_id = find_session_by_context(identifier)
|
|
62
|
+
|
|
63
|
+
Event.new(
|
|
64
|
+
channel: :github,
|
|
65
|
+
event_type: event_type,
|
|
66
|
+
payload: parsed,
|
|
67
|
+
trust: :external,
|
|
68
|
+
session_id: session_id,
|
|
69
|
+
received_at: Time.now
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Determines if the action is one we care about.
|
|
76
|
+
def actionable?(event_type, action)
|
|
77
|
+
case event_type
|
|
78
|
+
when :issue_comment
|
|
79
|
+
%w[created edited].include?(action)
|
|
80
|
+
when :pull_request_review_comment
|
|
81
|
+
%w[created edited].include?(action)
|
|
82
|
+
when :pull_request_review
|
|
83
|
+
%w[submitted edited].include?(action)
|
|
84
|
+
else
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Extracts relevant fields from the webhook payload.
|
|
90
|
+
def parse_event(event_type, payload)
|
|
91
|
+
case event_type
|
|
92
|
+
when :issue_comment
|
|
93
|
+
parse_issue_comment(payload)
|
|
94
|
+
when :pull_request_review_comment
|
|
95
|
+
parse_pr_review_comment(payload)
|
|
96
|
+
when :pull_request_review
|
|
97
|
+
parse_pr_review(payload)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_issue_comment(payload)
|
|
102
|
+
comment = payload_value(payload, :comment) || {}
|
|
103
|
+
issue = payload_value(payload, :issue) || {}
|
|
104
|
+
repo = payload_value(payload, :repository) || {}
|
|
105
|
+
pr = payload_value(issue, :pull_request)
|
|
106
|
+
|
|
107
|
+
# Only handle comments on pull requests, not issues
|
|
108
|
+
return nil unless pr
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
action: payload_value(payload, :action),
|
|
112
|
+
body: payload_value(comment, :body),
|
|
113
|
+
author: dig_value(comment, :user, :login),
|
|
114
|
+
pr_number: payload_value(issue, :number),
|
|
115
|
+
repo_full_name: payload_value(repo, :full_name),
|
|
116
|
+
comment_id: payload_value(comment, :id),
|
|
117
|
+
html_url: payload_value(comment, :html_url),
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_pr_review_comment(payload)
|
|
122
|
+
comment = payload_value(payload, :comment) || {}
|
|
123
|
+
pr = payload_value(payload, :pull_request) || {}
|
|
124
|
+
repo = payload_value(payload, :repository) || {}
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
action: payload_value(payload, :action),
|
|
128
|
+
body: payload_value(comment, :body),
|
|
129
|
+
author: dig_value(comment, :user, :login),
|
|
130
|
+
pr_number: payload_value(pr, :number),
|
|
131
|
+
repo_full_name: payload_value(repo, :full_name),
|
|
132
|
+
comment_id: payload_value(comment, :id),
|
|
133
|
+
path: payload_value(comment, :path),
|
|
134
|
+
diff_hunk: payload_value(comment, :diff_hunk),
|
|
135
|
+
html_url: payload_value(comment, :html_url),
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_pr_review(payload)
|
|
140
|
+
review = payload_value(payload, :review) || {}
|
|
141
|
+
pr = payload_value(payload, :pull_request) || {}
|
|
142
|
+
repo = payload_value(payload, :repository) || {}
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
action: payload_value(payload, :action),
|
|
146
|
+
body: payload_value(review, :body),
|
|
147
|
+
author: dig_value(review, :user, :login),
|
|
148
|
+
state: payload_value(review, :state),
|
|
149
|
+
pr_number: payload_value(pr, :number),
|
|
150
|
+
repo_full_name: payload_value(repo, :full_name),
|
|
151
|
+
review_id: payload_value(review, :id),
|
|
152
|
+
html_url: payload_value(review, :html_url),
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Builds the channel_context identifier string: "owner/repo#pr_number"
|
|
157
|
+
def build_identifier(parsed)
|
|
158
|
+
repo = parsed[:repo_full_name]
|
|
159
|
+
pr = parsed[:pr_number]
|
|
160
|
+
return nil unless repo && pr
|
|
161
|
+
|
|
162
|
+
"#{repo}##{pr}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Searches the session store for a suspended session with matching channel_context.
|
|
166
|
+
def find_session_by_context(identifier)
|
|
167
|
+
return nil unless identifier
|
|
168
|
+
return nil unless @store.respond_to?(:ids)
|
|
169
|
+
|
|
170
|
+
@store.ids.each do |id|
|
|
171
|
+
session = @store.load(id)
|
|
172
|
+
next unless session
|
|
173
|
+
next unless session.state == :suspended
|
|
174
|
+
|
|
175
|
+
context = session.metadata[:channel_context]
|
|
176
|
+
next unless context.is_a?(Hash)
|
|
177
|
+
next unless context[:channel]&.to_sym == :github
|
|
178
|
+
next unless context[:identifier] == identifier
|
|
179
|
+
|
|
180
|
+
return session.id
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Safe hash value access supporting both symbol and string keys.
|
|
187
|
+
def payload_value(hash, key)
|
|
188
|
+
return nil unless hash.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
hash[key] || hash[key.to_s]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Safe nested hash access.
|
|
194
|
+
def dig_value(hash, *keys)
|
|
195
|
+
result = hash
|
|
196
|
+
keys.each do |key|
|
|
197
|
+
return nil unless result.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
result = result[key] || result[key.to_s]
|
|
200
|
+
end
|
|
201
|
+
result
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|