collavre_github 0.6.3 → 0.7.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 (25) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/collavre_github/creatives/integrations_controller.rb +253 -36
  3. data/app/controllers/collavre_github/webhooks_controller.rb +562 -37
  4. data/app/jobs/collavre_github/webhook_delivery_prune_job.rb +14 -0
  5. data/app/models/collavre_github/github_pr_channel.rb +72 -7
  6. data/app/models/collavre_github/repository_link.rb +61 -0
  7. data/app/models/collavre_github/webhook_delivery.rb +159 -0
  8. data/app/services/collavre_github/client.rb +14 -0
  9. data/app/services/collavre_github/pr_channel_state_updater.rb +83 -0
  10. data/app/services/collavre_github/repository_identity_synchronizer.rb +145 -0
  11. data/app/services/collavre_github/repository_provisioning_lock.rb +95 -0
  12. data/app/services/collavre_github/tools/concerns/pr_channel_locator.rb +50 -0
  13. data/app/services/collavre_github/tools/pr_monitor_service.rb +80 -61
  14. data/app/services/collavre_github/tools/pr_state_set_service.rb +170 -0
  15. data/app/services/collavre_github/webhook_provisioner.rb +600 -41
  16. data/app/services/collavre_github/webhook_reprovisioner.rb +118 -0
  17. data/config/locales/en.yml +1 -0
  18. data/config/locales/ko.yml +1 -0
  19. data/config/routes.rb +13 -3
  20. data/db/migrate/20260727000001_add_webhook_hook_id_to_github_repository_links.rb +17 -0
  21. data/db/migrate/20260727000002_create_github_webhook_deliveries.rb +30 -0
  22. data/db/migrate/20260729000000_add_repository_id_index_to_github_repository_links.rb +13 -0
  23. data/db/seeds.rb +1 -0
  24. data/lib/collavre_github/version.rb +1 -1
  25. metadata +12 -1
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "monitor"
5
+
6
+ module CollavreGithub
7
+ # Serializes link persistence and remote webhook mutation for one stable
8
+ # GitHub repository identity. Without this boundary, two creatives attaching
9
+ # a previously unseen repository can each provision from an uncommitted link
10
+ # and publish different secrets (or create duplicate hooks).
11
+ class RepositoryProvisioningLock
12
+ LOCK_PURPOSE = "collavre_github:repository_provisioning".freeze
13
+
14
+ @mutex_registry_guard = Mutex.new
15
+ @mutex_registry = {}
16
+
17
+ class << self
18
+ def with_lock(repository_id, &block)
19
+ key = repository_id.to_s
20
+ raise ArgumentError, "repository_id is required" if key.blank?
21
+
22
+ with_key_lock(key, &block)
23
+ end
24
+
25
+ def with_repository_name_lock(full_name, &block)
26
+ normalized_name = full_name.to_s.downcase
27
+ raise ArgumentError, "repository full name is required" if normalized_name.blank?
28
+
29
+ with_key_lock("name:#{normalized_name}", &block)
30
+ end
31
+
32
+ private
33
+
34
+ def with_key_lock(key, &block)
35
+ if postgres?
36
+ with_postgres_lock(key, &block)
37
+ else
38
+ # SQLite is used by tests and the single-process desktop app. Its
39
+ # adapter has no advisory-lock primitive, so use the process boundary.
40
+ with_process_lock(key, &block)
41
+ end
42
+ end
43
+
44
+ def postgres?
45
+ CollavreGithub::RepositoryLink.connection.adapter_name.casecmp?("PostgreSQL")
46
+ end
47
+
48
+ def with_postgres_lock(key)
49
+ CollavreGithub::RepositoryLink.connection_pool.with_connection do |connection|
50
+ advisory_key = advisory_lock_key(key)
51
+ # Schedule the matching unlock before asking PostgreSQL to acquire.
52
+ # If an asynchronous exception lands after PostgreSQL grants the lock
53
+ # but before the adapter returns, the ensure still releases it rather
54
+ # than returning a locked session to the connection pool.
55
+ unlock_required = true
56
+ connection.select_value("SELECT pg_advisory_lock(#{connection.quote(advisory_key)})")
57
+ yield
58
+ ensure
59
+ if unlock_required
60
+ connection.select_value("SELECT pg_advisory_unlock(#{connection.quote(advisory_key)})")
61
+ end
62
+ end
63
+ end
64
+
65
+ def advisory_lock_key(key)
66
+ Digest::SHA256.digest("#{LOCK_PURPOSE}:#{key}").unpack1("q>")
67
+ end
68
+
69
+ def with_process_lock(key)
70
+ mutex = register_mutex(key)
71
+ mutex.synchronize { yield }
72
+ ensure
73
+ unregister_mutex(key, mutex) if mutex
74
+ end
75
+
76
+ def register_mutex(key)
77
+ @mutex_registry_guard.synchronize do
78
+ entry = (@mutex_registry[key] ||= { mutex: Monitor.new, users: 0 })
79
+ entry[:users] += 1
80
+ entry[:mutex]
81
+ end
82
+ end
83
+
84
+ def unregister_mutex(key, mutex)
85
+ @mutex_registry_guard.synchronize do
86
+ entry = @mutex_registry[key]
87
+ return unless entry && entry[:mutex].equal?(mutex)
88
+
89
+ entry[:users] -= 1
90
+ @mutex_registry.delete(key) if entry[:users].zero?
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreGithub
4
+ module Tools
5
+ module Concerns
6
+ # Shared PR-URL parsing and channel lookup for the PR-channel tools
7
+ # (pr_monitor attaches, pr_state_set corrects). Both must agree on how a
8
+ # URL maps to a stored channel or a state correction would silently miss
9
+ # the very channel the monitor created.
10
+ module PrChannelLocator
11
+ extend ActiveSupport::Concern
12
+
13
+ PR_URL_RE = %r{\Ahttps?://github\.com/([^/]+/[^/]+)/pull/(\d+)\z}.freeze
14
+
15
+ private
16
+
17
+ # GitHub owner/repo identifiers are case-insensitive but webhook
18
+ # payloads always carry the canonical case. Normalize on parse so user
19
+ # input like "Owner/Repo" still matches incoming events.
20
+ #
21
+ # @return [[String, Integer]] downcased repo full name and PR number
22
+ def parse_pr_url(pr_url)
23
+ m = pr_url.to_s.match(PR_URL_RE)
24
+ raise ArgumentError, "Invalid PR URL: #{pr_url}" unless m
25
+
26
+ [ m[1].downcase, m[2].to_i ]
27
+ end
28
+
29
+ # Ruby-level compare instead of a WHERE: legacy rows can carry
30
+ # mixed-case repo names, and the dispatch path matches the same way.
31
+ def lookup_channel(topic, repo, pr_number)
32
+ CollavreGithub::GithubPrChannel.where(topic_id: topic.id).find do |c|
33
+ c.repo_full_name.to_s.downcase == repo.downcase && c.pr_number == pr_number
34
+ end
35
+ end
36
+
37
+ # Creatives whose RepositoryLinks govern this topic: the topic's own
38
+ # creative plus its ancestors. Mirrors pr_monitor's provisioning scope,
39
+ # so a tool can never reach a GitHub account the monitor itself could
40
+ # not have used.
41
+ def scoped_creative_ids(topic)
42
+ creative = topic.creative
43
+ return [] unless creative
44
+
45
+ [ creative.id ] + creative.ancestors.pluck(:id)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -8,14 +8,14 @@ module CollavreGithub
8
8
  class PrMonitorService
9
9
  extend T::Sig
10
10
  extend ToolMeta
11
-
12
- PR_URL_RE = %r{\Ahttps?://github\.com/([^/]+/[^/]+)/pull/(\d+)\z}.freeze
11
+ include Concerns::PrChannelLocator
13
12
 
14
13
  tool_name "pr_monitor"
15
14
  tool_description <<~DESC.strip
16
15
  Attach a GitHub PR monitor to a Collavre topic. After attachment,
17
16
  PR comments, review comments, and review submissions are injected
18
- into the topic as chat messages. Idempotent.
17
+ into the topic as chat messages. The response identifies the attached
18
+ topic and creative and uses a typed channel reference. Idempotent.
19
19
  DESC
20
20
 
21
21
  tool_param :topic_id, description: "The Collavre topic id to attach the PR channel to."
@@ -23,13 +23,7 @@ module CollavreGithub
23
23
 
24
24
  sig { params(topic_id: Integer, pr_url: String).returns(T::Hash[Symbol, T.untyped]) }
25
25
  def call(topic_id:, pr_url:)
26
- m = pr_url.match(PR_URL_RE)
27
- raise ArgumentError, "Invalid PR URL: #{pr_url}" unless m
28
- # GitHub owner/repo identifiers are case-insensitive but webhook payloads
29
- # always carry the canonical case. Normalize on store so user input
30
- # like "Owner/Repo" still matches incoming events.
31
- repo = m[1].downcase
32
- pr_number = m[2].to_i
26
+ repo, pr_number = parse_pr_url(pr_url)
33
27
 
34
28
  topic = Collavre::Topic.find(topic_id)
35
29
  Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
@@ -41,7 +35,14 @@ module CollavreGithub
41
35
  channel.inject_into_topic!(channel.attached_message)
42
36
  end
43
37
 
44
- result = { ok: true, channel_id: channel.id, repo: repo, pr_number: pr_number }
38
+ result = {
39
+ ok: true,
40
+ channel_ref: "ch_#{channel.id}",
41
+ topic: { id: topic.id, name: topic.name },
42
+ creative: { id: topic.creative.id, title: topic.creative.creative_snippet },
43
+ repo: repo,
44
+ pr_number: pr_number
45
+ }
45
46
  warning = ensure_webhook_events(topic, repo)
46
47
  result[:webhook_warning] = warning if warning
47
48
  result
@@ -70,13 +71,6 @@ module CollavreGithub
70
71
  )
71
72
  end
72
73
 
73
- sig { params(topic: Collavre::Topic, repo: String, pr_number: Integer).returns(T.nilable(CollavreGithub::GithubPrChannel)) }
74
- def lookup_channel(topic, repo, pr_number)
75
- CollavreGithub::GithubPrChannel.where(topic_id: topic.id).find do |c|
76
- c.repo_full_name.to_s.downcase == repo.downcase && c.pr_number == pr_number
77
- end
78
- end
79
-
80
74
  # Make sure the repo's webhook subscribes to the PR-channel events
81
75
  # (issue_comment / pull_request_review / pull_request_review_comment).
82
76
  # Without these, GitHub never delivers comment payloads and the channel
@@ -84,62 +78,87 @@ module CollavreGithub
84
78
  # Returns a warning string when provisioning cannot run or fails; nil on
85
79
  # success so the MCP response stays clean.
86
80
  def ensure_webhook_events(topic, repo)
87
- scoped_link = scoped_repository_link_for(topic, repo)
88
- return "no RepositoryLink found for #{repo} in topic creative scope; webhook events not auto-provisioned" unless scoped_link
89
-
90
- # Provision through the *global* primary link (lowest id across all
91
- # creatives), not the scoped link. WebhookProvisioner only patches hook
92
- # events when the link IS the primary; non-primary links short-circuit
93
- # to secret alignment and skip the GitHub edit_hook call. So if we
94
- # provisioned the scoped link and it was not the global primary, the
95
- # existing hook would keep its old event list. The scoped link is only
96
- # used as an authorization gate above.
97
- provisioning_link = global_primary_repository_link_for(repo) || scoped_link
98
-
99
- account = provisioning_link.github_account
100
- return "RepositoryLink for #{repo} has no GitHub account; webhook events not auto-provisioned" unless account
101
-
102
- results = CollavreGithub::WebhookProvisioner.ensure_for_links(
103
- account: account,
104
- links: [ provisioning_link ],
105
- webhook_url: github_webhook_url
106
- )
107
- status = results.first&.last
108
- # :failed means Client returned nil (Octokit/Faraday error rescued in
109
- # CollavreGithub::Client). Surface that to the MCP caller so they know
110
- # webhook events were not actually patched.
111
- return "webhook provisioning failed: GitHub API rejected the hook request (see logs)" if status == :failed
112
- nil
81
+ scoped_repository_ids(topic, repo).each do |repository_id|
82
+ outcome = CollavreGithub::RepositoryProvisioningLock.with_lock(repository_id) do
83
+ scoped_links = verified_scoped_repository_links_for(
84
+ topic,
85
+ repo,
86
+ repository_id: repository_id
87
+ )
88
+ next :unverified if scoped_links.empty?
89
+
90
+ all_failed = scoped_links.all? do |scoped_link|
91
+ results = CollavreGithub::WebhookProvisioner.ensure_for_links(
92
+ account: scoped_link.github_account,
93
+ links: [ scoped_link ],
94
+ webhook_url: github_webhook_url,
95
+ force_hook_refresh: true
96
+ )
97
+ results.first&.last == :failed
98
+ end
99
+ if all_failed
100
+ "webhook provisioning failed: GitHub API rejected the hook request (see logs)"
101
+ end
102
+ end
103
+ next if outcome == :unverified
104
+
105
+ # A nil outcome means a verified hook was refreshed successfully.
106
+ # :shared is also success: the registered hook was patched before
107
+ # WebhookProvisioner returned that status.
108
+ return outcome
109
+ end
110
+
111
+ "no verified RepositoryLink found for #{repo} in topic creative scope; " \
112
+ "webhook events not auto-provisioned"
113
113
  rescue => e
114
114
  Rails.logger.warn("[pr_monitor] webhook provisioning failed for #{repo}: #{e.class}: #{e.message}")
115
115
  "webhook provisioning failed: #{e.message}"
116
116
  end
117
117
 
118
- # Authorization gate: a RepositoryLink for `repo` must live in this
119
- # topic's creative subtree (the topic creative itself or one of its
120
- # ancestors). Without one, the dispatch path in WebhooksController would
121
- # silently drop events for this topic anyway.
122
- def scoped_repository_link_for(topic, repo)
123
- creative = topic.creative
124
- return nil unless creative
118
+ def scoped_repository_ids(topic, repo)
119
+ candidate_ids = scoped_creative_ids(topic)
120
+ return [] if candidate_ids.empty?
125
121
 
126
- candidate_ids = [ creative.id ] + creative.ancestors.pluck(:id)
127
122
  CollavreGithub::RepositoryLink
128
123
  .where("LOWER(repository_full_name) = ?", repo.downcase)
129
124
  .where(creative_id: candidate_ids)
130
- .order(:id)
131
- .first
125
+ .where.not(repository_id: nil)
126
+ .distinct
127
+ .pluck(:repository_id)
132
128
  end
133
129
 
134
- # Mirrors WebhookProvisioner#primary_link_for: the lowest-id link for the
135
- # repo across ALL creatives. This is the link whose secret + events the
136
- # hook is aligned to, so we must provision through it to trigger an
137
- # actual edit_hook call.
138
- def global_primary_repository_link_for(repo)
139
- CollavreGithub::RepositoryLink
130
+ # Authorization and identity gate: a RepositoryLink for `repo` must live
131
+ # in this topic's creative subtree, carry a stable repository id, and
132
+ # resolve to that same id through its GitHub account. A stored name alone
133
+ # is not safe provisioning evidence because GitHub can reuse a renamed
134
+ # repository's old name.
135
+ def verified_scoped_repository_links_for(topic, repo, repository_id:)
136
+ candidate_ids = scoped_creative_ids(topic)
137
+ return [] if candidate_ids.empty?
138
+
139
+ candidates = CollavreGithub::RepositoryLink
140
140
  .where("LOWER(repository_full_name) = ?", repo.downcase)
141
+ .where(creative_id: candidate_ids)
142
+ .where(repository_id: repository_id)
141
143
  .order(:id)
142
- .first
144
+ .to_a
145
+ identities = {}
146
+
147
+ candidates.uniq(&:github_account_id).select do |link|
148
+ account = link.github_account
149
+ next false if link.repository_id.blank? || account.nil?
150
+
151
+ identity = identities.fetch(account.id) do
152
+ identities[account.id] = CollavreGithub::Client.new(account).repository_identity(repo)
153
+ rescue Octokit::Error, Faraday::Error => e
154
+ Rails.logger.warn(
155
+ "[pr_monitor] repository identity lookup failed for #{repo} through " \
156
+ "account #{account.id}: #{e.class}: #{e.message}"
157
+ )
158
+ identities[account.id] = nil
159
+ end
160
+ identity&.id.to_s == link.repository_id.to_s
161
+ end
143
162
  end
144
163
 
145
164
  def github_webhook_url
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+ require "rails_mcp_engine"
5
+
6
+ module CollavreGithub
7
+ module Tools
8
+ class PrStateSetService
9
+ extend T::Sig
10
+ extend ToolMeta
11
+ include Concerns::PrChannelLocator
12
+
13
+ tool_name "pr_state_set"
14
+ tool_description <<~DESC.strip
15
+ Correct the state of a GitHub PR channel attached with pr_monitor, for
16
+ when a `pull_request` webhook was missed (hook added after the merge,
17
+ delivery failure) and the chip is stuck on the wrong badge.
18
+
19
+ Omit `state` to resynchronize from the GitHub API — that is the
20
+ preferred form, because it cannot record a state the PR does not
21
+ actually have. Pass `state` explicitly only as a fallback when no
22
+ GitHub account is connected for the repository.
23
+
24
+ Closing the channel (merged / closed_without_merge) posts the closing
25
+ message and detaches it; reopening it resumes monitoring. Idempotent.
26
+ DESC
27
+
28
+ tool_param :topic_id, description: "The Collavre topic id the PR channel is attached to."
29
+ tool_param :pr_url, description: "Full GitHub PR URL, e.g. https://github.com/owner/repo/pull/123"
30
+ tool_param :state,
31
+ description: "Target state. Omit to read the real state from GitHub.",
32
+ required: false,
33
+ enum: CollavreGithub::GithubPrChannel::PR_STATES
34
+
35
+ sig do
36
+ params(
37
+ topic_id: Integer,
38
+ pr_url: String,
39
+ state: T.nilable(String)
40
+ ).returns(T::Hash[Symbol, T.untyped])
41
+ end
42
+ def call(topic_id:, pr_url:, state: nil)
43
+ repo, pr_number = parse_pr_url(pr_url)
44
+
45
+ topic = Collavre::Topic.find(topic_id)
46
+ Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
47
+
48
+ channel = lookup_channel(topic, repo, pr_number)
49
+ unless channel
50
+ return {
51
+ ok: false,
52
+ error: "No PR channel for #{repo}##{pr_number} on topic #{topic_id}. Attach it with pr_monitor first."
53
+ }
54
+ end
55
+
56
+ target, source = resolve_state(topic, repo, pr_number, state)
57
+ return target if target.is_a?(Hash) # error payload
58
+
59
+ result = CollavreGithub::PrChannelStateUpdater.call(channel: channel, state: target)
60
+ {
61
+ ok: true,
62
+ channel_id: channel.id,
63
+ repo: repo,
64
+ pr_number: pr_number,
65
+ pr_state: target,
66
+ previous_state: result.previous_state,
67
+ status: result.status.to_s,
68
+ state_source: source
69
+ }
70
+ end
71
+
72
+ private
73
+
74
+ # Returns [state, source] or an error hash. An explicit `state` is
75
+ # validated here rather than at the updater so a typo surfaces as an
76
+ # ArgumentError naming the tool parameter the caller actually passed.
77
+ sig do
78
+ params(
79
+ topic: Collavre::Topic,
80
+ repo: String,
81
+ pr_number: Integer,
82
+ state: T.nilable(String)
83
+ ).returns([ T.any(String, T::Hash[Symbol, T.untyped]), String ])
84
+ end
85
+ def resolve_state(topic, repo, pr_number, state)
86
+ if state.present?
87
+ unless CollavreGithub::GithubPrChannel::PR_STATES.include?(state)
88
+ raise ArgumentError,
89
+ "Invalid state: #{state.inspect} (expected one of #{CollavreGithub::GithubPrChannel::PR_STATES.join(', ')})"
90
+ end
91
+ return [ state, "explicit" ]
92
+ end
93
+
94
+ clients = verified_github_clients_for(topic, repo)
95
+ if clients.empty?
96
+ return [ {
97
+ ok: false,
98
+ error: "No verified connected GitHub account for #{repo} in this topic's creative scope, " \
99
+ "so the state could not be read from GitHub. Pass `state` explicitly instead."
100
+ }, "github" ]
101
+ end
102
+
103
+ clients.each do |client|
104
+ remote = remote_state(client, repo, pr_number)
105
+ return [ remote, "github" ] if remote
106
+ end
107
+
108
+ [ {
109
+ ok: false,
110
+ error: "Could not read #{repo}##{pr_number} from GitHub (not found, or the API call failed). " \
111
+ "Pass `state` explicitly instead."
112
+ }, "github" ]
113
+ end
114
+
115
+ # GitHub reports merge and closure separately: `merged` is the
116
+ # authoritative merge flag, and `state` only distinguishes open from
117
+ # closed. Checking `merged` first is what keeps a squash-merged PR from
118
+ # being recorded as closed_without_merge.
119
+ def remote_state(client, repo, pr_number)
120
+ pr = client.pull_request_details(repo, pr_number)
121
+ return nil unless pr
122
+ return "merged" if pr.merged
123
+
124
+ pr.state.to_s == "closed" ? "closed_without_merge" : "open"
125
+ end
126
+
127
+ # A repository name is not an identity: GitHub can reuse it after a
128
+ # rename. Keep only accounts whose live repository id matches a stable id
129
+ # stored on an in-scope RepositoryLink, and reject the entire inherited
130
+ # scope when another link claims the same name with a conflicting id.
131
+ # Then let resync try each verified account until one can read the PR.
132
+ def verified_github_clients_for(topic, repo)
133
+ candidate_ids = scoped_creative_ids(topic)
134
+ return [] if candidate_ids.empty?
135
+
136
+ links = CollavreGithub::RepositoryLink
137
+ .where("LOWER(repository_full_name) = ?", repo.downcase)
138
+ .where(creative_id: candidate_ids)
139
+ .where.not(repository_id: nil)
140
+ .order(:id)
141
+ .to_a
142
+
143
+ links.group_by(&:github_account_id).filter_map do |account_id, account_links|
144
+ verified_client_for(account_id, account_links, topic, repo)
145
+ end
146
+ end
147
+
148
+ def verified_client_for(account_id, links, topic, repo)
149
+ client = CollavreGithub::Client.new(links.first.github_account)
150
+ identity = client.repository_identity(repo)
151
+ return nil if CollavreGithub::RepositoryLink.conflicting_repository_in_scope?(
152
+ creative: topic.creative,
153
+ full_name: repo,
154
+ repository_id: identity.id
155
+ )
156
+
157
+ return client if links.any? { |link| link.repository_id.to_s == identity.id.to_s }
158
+
159
+ nil
160
+ rescue Octokit::Error, Faraday::Error => e
161
+ Rails.logger.warn(
162
+ "[pr_state_set] repository identity lookup failed for #{repo} through " \
163
+ "account #{account_id}: #{e.class}: #{e.message}"
164
+ )
165
+
166
+ nil
167
+ end
168
+ end
169
+ end
170
+ end