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.
- checksums.yaml +4 -4
- data/app/controllers/collavre_github/creatives/integrations_controller.rb +253 -36
- data/app/controllers/collavre_github/webhooks_controller.rb +562 -37
- data/app/jobs/collavre_github/webhook_delivery_prune_job.rb +14 -0
- data/app/models/collavre_github/github_pr_channel.rb +72 -7
- data/app/models/collavre_github/repository_link.rb +61 -0
- data/app/models/collavre_github/webhook_delivery.rb +159 -0
- data/app/services/collavre_github/client.rb +14 -0
- data/app/services/collavre_github/pr_channel_state_updater.rb +83 -0
- data/app/services/collavre_github/repository_identity_synchronizer.rb +145 -0
- data/app/services/collavre_github/repository_provisioning_lock.rb +95 -0
- data/app/services/collavre_github/tools/concerns/pr_channel_locator.rb +50 -0
- data/app/services/collavre_github/tools/pr_monitor_service.rb +80 -61
- data/app/services/collavre_github/tools/pr_state_set_service.rb +170 -0
- data/app/services/collavre_github/webhook_provisioner.rb +600 -41
- data/app/services/collavre_github/webhook_reprovisioner.rb +118 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/ko.yml +1 -0
- data/config/routes.rb +13 -3
- data/db/migrate/20260727000001_add_webhook_hook_id_to_github_repository_links.rb +17 -0
- data/db/migrate/20260727000002_create_github_webhook_deliveries.rb +30 -0
- data/db/migrate/20260729000000_add_repository_id_index_to_github_repository_links.rb +13 -0
- data/db/seeds.rb +1 -0
- data/lib/collavre_github/version.rb +1 -1
- 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.
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
.
|
|
131
|
-
.
|
|
125
|
+
.where.not(repository_id: nil)
|
|
126
|
+
.distinct
|
|
127
|
+
.pluck(:repository_id)
|
|
132
128
|
end
|
|
133
129
|
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
.
|
|
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
|