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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Cartographer
5
+ end
6
+ 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