prdigest 0.1.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/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/SECURITY.md +28 -0
- data/configs/config.example.yml +32 -0
- data/exe/prdigest +6 -0
- data/lib/prdigest/cli.rb +169 -0
- data/lib/prdigest/clock.rb +50 -0
- data/lib/prdigest/config.rb +136 -0
- data/lib/prdigest/delivery_checkpoint_store.rb +272 -0
- data/lib/prdigest/digest.rb +60 -0
- data/lib/prdigest/github.rb +199 -0
- data/lib/prdigest/renderer.rb +134 -0
- data/lib/prdigest/result.rb +82 -0
- data/lib/prdigest/runner.rb +173 -0
- data/lib/prdigest/schedule.rb +30 -0
- data/lib/prdigest/state.rb +147 -0
- data/lib/prdigest/telegram.rb +180 -0
- data/lib/prdigest/version.rb +5 -0
- data/lib/prdigest.rb +46 -0
- data/scripts/systemd/prdigest.service +27 -0
- data/scripts/systemd/prdigest.timer +12 -0
- metadata +186 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tzinfo"
|
|
6
|
+
|
|
7
|
+
module Prdigest
|
|
8
|
+
class Config
|
|
9
|
+
attr_reader :raw, :path
|
|
10
|
+
|
|
11
|
+
REPOSITORY_SEGMENT = /\A[A-Za-z0-9_.-]+\z/
|
|
12
|
+
|
|
13
|
+
def self.resolve_path(explicit: nil, env: ENV, system_path: "/etc/prdigest/config.yml")
|
|
14
|
+
return File.expand_path(explicit) if explicit && !explicit.to_s.empty?
|
|
15
|
+
|
|
16
|
+
from_env = env["PRDIGEST_CONFIG"].to_s
|
|
17
|
+
return File.expand_path(from_env) unless from_env.empty?
|
|
18
|
+
return system_path if File.file?(system_path)
|
|
19
|
+
|
|
20
|
+
raise ConfigError, "config path is required (--config, PRDIGEST_CONFIG, or /etc/prdigest/config.yml)"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.load(path)
|
|
24
|
+
path = File.expand_path(path)
|
|
25
|
+
raise ConfigError, "config not found: #{path}" unless File.file?(path)
|
|
26
|
+
|
|
27
|
+
raw = YAML.safe_load_file(path, aliases: true)
|
|
28
|
+
raise ConfigError, "config root must be a mapping" unless raw.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
new(raw, path: path).tap(&:validate!)
|
|
31
|
+
rescue ConfigError
|
|
32
|
+
raise
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
raise ConfigError, "cannot load config: #{e.class}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.normalize_repos(values, label: "repository")
|
|
38
|
+
normalized = Array(values).map { |value| value.to_s.strip }
|
|
39
|
+
raise ConfigError, "#{label} must list at least one owner/name" if normalized.empty?
|
|
40
|
+
|
|
41
|
+
invalid = normalized.find do |repository|
|
|
42
|
+
parts = repository.split("/", -1)
|
|
43
|
+
parts.length != 2 ||
|
|
44
|
+
parts.any? { |part| part.empty? || %w[. ..].include?(part) || !part.match?(REPOSITORY_SEGMENT) }
|
|
45
|
+
end
|
|
46
|
+
raise ConfigError, "#{label} entry must be owner/name; got #{invalid.inspect}" if invalid
|
|
47
|
+
|
|
48
|
+
normalized.each_with_object({}) do |repository, unique|
|
|
49
|
+
unique[repository.downcase] ||= repository
|
|
50
|
+
end.values.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(raw, path: nil)
|
|
54
|
+
@raw = raw
|
|
55
|
+
@path = path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def timezone
|
|
59
|
+
raw.fetch("timezone", "UTC").to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def repos
|
|
63
|
+
self.class.normalize_repos(raw.dig("github", "repos"), label: "github.repos")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def github_token(env = ENV)
|
|
67
|
+
env_name = raw.dig("github", "token_env") || "GITHUB_TOKEN"
|
|
68
|
+
env[env_name.to_s].to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def telegram_token(env = ENV)
|
|
72
|
+
env_name = raw.dig("telegram", "token_env") || "TELEGRAM_BOT_TOKEN"
|
|
73
|
+
env[env_name.to_s].to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def chat_id
|
|
77
|
+
Integer(raw.dig("telegram", "chat_id"))
|
|
78
|
+
rescue TypeError, ArgumentError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def chat_id_allowlist
|
|
83
|
+
Array(raw.dig("telegram", "chat_id_allowlist")).map { |id| Integer(id) }
|
|
84
|
+
rescue TypeError, ArgumentError
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def line_stats?
|
|
89
|
+
raw.dig("digest", "line_stats") != false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def send_empty?
|
|
93
|
+
raw.dig("digest", "send_empty") != false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def empty_message
|
|
97
|
+
raw.dig("digest", "empty_message") || "Merged PR digest — {date}\nTotal: 0 PRs"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def state_path
|
|
101
|
+
raw.dig("state", "path") || File.expand_path("~/.local/share/prdigest/state.json")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def delivery_state_path
|
|
105
|
+
raw.dig("state", "delivery_path") || File.join(File.dirname(state_path), "deliveries")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def schedule_cron
|
|
109
|
+
raw.dig("schedule", "cron") || "5 9 * * *"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def max_catchup_days
|
|
113
|
+
Integer(raw.dig("schedule", "max_catchup_days") || 7)
|
|
114
|
+
rescue TypeError, ArgumentError
|
|
115
|
+
raise ConfigError, "schedule.max_catchup_days must be an integer from 1 to 30"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate!
|
|
119
|
+
begin
|
|
120
|
+
TZInfo::Timezone.get(timezone)
|
|
121
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
122
|
+
raise ConfigError, "timezone must be a resolvable IANA identifier"
|
|
123
|
+
end
|
|
124
|
+
unless (1..30).cover?(max_catchup_days)
|
|
125
|
+
raise ConfigError, "schedule.max_catchup_days must be from 1 to 30"
|
|
126
|
+
end
|
|
127
|
+
repos
|
|
128
|
+
raise ConfigError, "telegram.chat_id is required" if chat_id.nil?
|
|
129
|
+
raise ConfigError, "telegram.chat_id_allowlist must not be empty" if chat_id_allowlist.empty?
|
|
130
|
+
unless chat_id_allowlist.include?(chat_id)
|
|
131
|
+
raise ConfigError, "telegram.chat_id must be present in chat_id_allowlist"
|
|
132
|
+
end
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Prdigest
|
|
10
|
+
class DeliveryCheckpointStore
|
|
11
|
+
SCHEMA = "prdigest-delivery-checkpoint"
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
class Session
|
|
15
|
+
def initialize(path:, data:)
|
|
16
|
+
@path = path
|
|
17
|
+
@data = data
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def chunks
|
|
21
|
+
@data.fetch("chunks")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def next_chunk
|
|
25
|
+
@data.fetch("next_chunk")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def completed?
|
|
29
|
+
@data.fetch("status") == "completed"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def begin_attempt(index)
|
|
33
|
+
require_next_chunk!(index)
|
|
34
|
+
@data["in_flight"] = { "chunk" => index, "started_at" => timestamp }
|
|
35
|
+
persist!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def accept(index)
|
|
39
|
+
require_in_flight!(index)
|
|
40
|
+
@data["next_chunk"] = index + 1
|
|
41
|
+
@data["in_flight"] = nil
|
|
42
|
+
@data["status"] = next_chunk == chunks.length ? "completed" : "pending"
|
|
43
|
+
@data["permanent_error"] = nil
|
|
44
|
+
persist!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reject(index, kind:, message:, permanent:)
|
|
48
|
+
require_in_flight!(index)
|
|
49
|
+
@data["in_flight"] = nil
|
|
50
|
+
if permanent
|
|
51
|
+
@data["status"] = "blocked"
|
|
52
|
+
@data["permanent_error"] = {
|
|
53
|
+
"kind" => kind.to_s,
|
|
54
|
+
"message" => message.to_s,
|
|
55
|
+
"failed_chunk" => index
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
@data["permanent_error"] = nil
|
|
59
|
+
end
|
|
60
|
+
persist!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def delivery(failed_chunk: nil)
|
|
64
|
+
result = {
|
|
65
|
+
accepted_chunks: next_chunk,
|
|
66
|
+
total_chunks: chunks.length,
|
|
67
|
+
status: @data.fetch("status")
|
|
68
|
+
}
|
|
69
|
+
result[:failed_chunk] = failed_chunk unless failed_chunk.nil?
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def require_next_chunk!(index)
|
|
76
|
+
return if index == next_chunk && @data["in_flight"].nil? && !completed?
|
|
77
|
+
|
|
78
|
+
raise StateError, "delivery checkpoint transition is invalid"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def require_in_flight!(index)
|
|
82
|
+
return if @data.dig("in_flight", "chunk") == index
|
|
83
|
+
|
|
84
|
+
raise StateError, "delivery checkpoint transition is invalid"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def persist!
|
|
88
|
+
@data["updated_at"] = timestamp
|
|
89
|
+
DeliveryCheckpointStore.atomic_write(@path, JSON.pretty_generate(@data))
|
|
90
|
+
rescue StateError
|
|
91
|
+
raise
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
raise StateError, "cannot persist delivery checkpoint (#{e.class})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def timestamp
|
|
97
|
+
Time.now.utc.iso8601(6)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def initialize(root:)
|
|
102
|
+
@root = File.expand_path(root)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def with_checkpoint(date:, chat_id:, scope:, chunks:)
|
|
106
|
+
normalized_date = Date.iso8601(date.to_s)
|
|
107
|
+
ensure_root!
|
|
108
|
+
path = File.join(@root, "#{normalized_date}.json")
|
|
109
|
+
lock_path = File.join(@root, "#{normalized_date}.lock")
|
|
110
|
+
|
|
111
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
112
|
+
File.chmod(0o600, lock_path)
|
|
113
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
114
|
+
raise SendError.new(
|
|
115
|
+
"delivery checkpoint is already active",
|
|
116
|
+
kind: "delivery_checkpoint",
|
|
117
|
+
delivery: { accepted_chunks: 0, total_chunks: Array(chunks).length, status: "pending" }
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
data = load_or_create(
|
|
122
|
+
path,
|
|
123
|
+
date: normalized_date,
|
|
124
|
+
chat_id: Integer(chat_id),
|
|
125
|
+
scope: Array(scope).map(&:to_s),
|
|
126
|
+
chunks: Array(chunks).map(&:to_s)
|
|
127
|
+
)
|
|
128
|
+
fail_if_unavailable!(data)
|
|
129
|
+
yield Session.new(path: path, data: data)
|
|
130
|
+
ensure
|
|
131
|
+
lock.flock(File::LOCK_UN)
|
|
132
|
+
end
|
|
133
|
+
rescue SendError, StateError
|
|
134
|
+
raise
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
raise StateError, "cannot access delivery checkpoint (#{e.class})"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.atomic_write(path, content)
|
|
140
|
+
directory = File.dirname(path)
|
|
141
|
+
temporary = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
142
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
143
|
+
file.write(content)
|
|
144
|
+
file.flush
|
|
145
|
+
file.fsync
|
|
146
|
+
end
|
|
147
|
+
File.rename(temporary, path)
|
|
148
|
+
File.chmod(0o600, path)
|
|
149
|
+
File.open(directory, File::RDONLY, &:fsync)
|
|
150
|
+
ensure
|
|
151
|
+
File.unlink(temporary) if temporary && File.exist?(temporary)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def ensure_root!
|
|
157
|
+
FileUtils.mkdir_p(@root, mode: 0o700)
|
|
158
|
+
File.chmod(0o700, @root)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def load_or_create(path, date:, chat_id:, scope:, chunks:)
|
|
162
|
+
if File.file?(path)
|
|
163
|
+
data = JSON.parse(File.read(path))
|
|
164
|
+
validate!(data, date: date, chat_id: chat_id, scope: scope)
|
|
165
|
+
data
|
|
166
|
+
else
|
|
167
|
+
data = {
|
|
168
|
+
"schema" => SCHEMA,
|
|
169
|
+
"schema_version" => SCHEMA_VERSION,
|
|
170
|
+
"date" => date.to_s,
|
|
171
|
+
"chat_id" => chat_id,
|
|
172
|
+
"scope" => scope,
|
|
173
|
+
"chunks" => chunks,
|
|
174
|
+
"chunks_sha256" => chunk_digest(chunks),
|
|
175
|
+
"next_chunk" => 0,
|
|
176
|
+
"status" => chunks.empty? ? "completed" : "pending",
|
|
177
|
+
"in_flight" => nil,
|
|
178
|
+
"permanent_error" => nil,
|
|
179
|
+
"created_at" => Time.now.utc.iso8601(6),
|
|
180
|
+
"updated_at" => Time.now.utc.iso8601(6)
|
|
181
|
+
}
|
|
182
|
+
self.class.atomic_write(path, JSON.pretty_generate(data))
|
|
183
|
+
data
|
|
184
|
+
end
|
|
185
|
+
rescue SendError
|
|
186
|
+
raise
|
|
187
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
|
|
188
|
+
raise SendError.new(
|
|
189
|
+
"delivery checkpoint is invalid (#{e.class})",
|
|
190
|
+
kind: "delivery_checkpoint_permanent"
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate!(data, date:, chat_id:, scope:)
|
|
195
|
+
expected = {
|
|
196
|
+
"schema" => SCHEMA,
|
|
197
|
+
"schema_version" => SCHEMA_VERSION,
|
|
198
|
+
"date" => date.to_s,
|
|
199
|
+
"chat_id" => chat_id,
|
|
200
|
+
"scope" => scope
|
|
201
|
+
}
|
|
202
|
+
unless expected.all? { |key, value| data.fetch(key) == value }
|
|
203
|
+
raise SendError.new(
|
|
204
|
+
"delivery checkpoint does not match this digest",
|
|
205
|
+
kind: "delivery_checkpoint_permanent"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
chunks = data.fetch("chunks")
|
|
210
|
+
next_chunk = data.fetch("next_chunk")
|
|
211
|
+
status = data.fetch("status")
|
|
212
|
+
valid = chunks.is_a?(Array) && chunks.all? { |chunk| chunk.is_a?(String) } &&
|
|
213
|
+
data.fetch("chunks_sha256") == chunk_digest(chunks) &&
|
|
214
|
+
next_chunk.is_a?(Integer) && next_chunk.between?(0, chunks.length) &&
|
|
215
|
+
valid_transition_state?(data, status: status, next_chunk: next_chunk, total_chunks: chunks.length)
|
|
216
|
+
raise KeyError, "invalid delivery checkpoint fields" unless valid
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def valid_transition_state?(data, status:, next_chunk:, total_chunks:)
|
|
220
|
+
in_flight = data.fetch("in_flight")
|
|
221
|
+
permanent_error = data.fetch("permanent_error")
|
|
222
|
+
case status
|
|
223
|
+
when "pending"
|
|
224
|
+
next_chunk < total_chunks &&
|
|
225
|
+
valid_in_flight?(in_flight, next_chunk) &&
|
|
226
|
+
permanent_error.nil?
|
|
227
|
+
when "completed"
|
|
228
|
+
next_chunk == total_chunks && in_flight.nil? && permanent_error.nil?
|
|
229
|
+
when "blocked"
|
|
230
|
+
next_chunk < total_chunks && in_flight.nil? &&
|
|
231
|
+
permanent_error.is_a?(Hash) &&
|
|
232
|
+
permanent_error["kind"].is_a?(String) &&
|
|
233
|
+
permanent_error["message"].is_a?(String) &&
|
|
234
|
+
permanent_error["failed_chunk"] == next_chunk
|
|
235
|
+
else
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def valid_in_flight?(in_flight, next_chunk)
|
|
241
|
+
in_flight.nil? ||
|
|
242
|
+
(in_flight.is_a?(Hash) &&
|
|
243
|
+
in_flight["chunk"] == next_chunk &&
|
|
244
|
+
in_flight["started_at"].is_a?(String))
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def fail_if_unavailable!(data)
|
|
248
|
+
session = Session.new(path: "", data: data)
|
|
249
|
+
if data["in_flight"]
|
|
250
|
+
failed_chunk = data.dig("in_flight", "chunk")
|
|
251
|
+
raise SendError.new(
|
|
252
|
+
"Telegram delivery outcome is ambiguous; operator reconciliation is required",
|
|
253
|
+
kind: "telegram_ambiguous",
|
|
254
|
+
delivery: session.delivery(failed_chunk: failed_chunk)
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
return unless data["status"] == "blocked"
|
|
259
|
+
|
|
260
|
+
failure = data.fetch("permanent_error")
|
|
261
|
+
raise SendError.new(
|
|
262
|
+
failure.fetch("message"),
|
|
263
|
+
kind: failure.fetch("kind"),
|
|
264
|
+
delivery: session.delivery(failed_chunk: failure.fetch("failed_chunk"))
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def chunk_digest(chunks)
|
|
269
|
+
::Digest::SHA256.hexdigest(JSON.generate(chunks))
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Prdigest
|
|
6
|
+
PullRequest = Data.define(
|
|
7
|
+
:repository, :number, :title, :url, :author, :merged_at,
|
|
8
|
+
:additions, :deletions, :commits
|
|
9
|
+
) do
|
|
10
|
+
def initialize(repository:, number:, title:, url:, author:, merged_at:, additions: nil, deletions: nil, commits: nil)
|
|
11
|
+
super(
|
|
12
|
+
repository: repository.to_s.freeze,
|
|
13
|
+
number: Integer(number),
|
|
14
|
+
title: title.to_s.freeze,
|
|
15
|
+
url: url.to_s.freeze,
|
|
16
|
+
author: author.to_s.freeze,
|
|
17
|
+
merged_at: merged_at.utc.freeze,
|
|
18
|
+
additions: additions && Integer(additions),
|
|
19
|
+
deletions: deletions && Integer(deletions),
|
|
20
|
+
commits: commits && Integer(commits)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
RepositoryDigest = Data.define(:name, :pull_requests)
|
|
26
|
+
|
|
27
|
+
DayDigest = Data.define(:date, :repositories, :line_stats) do
|
|
28
|
+
def self.build(date:, repository_order:, pulls:, line_stats: false)
|
|
29
|
+
grouped = pulls.group_by(&:repository)
|
|
30
|
+
sections = repository_order.map do |name|
|
|
31
|
+
sorted = Array(grouped[name]).sort_by { |pull| [pull.merged_at, pull.number] }.freeze
|
|
32
|
+
RepositoryDigest.new(name.to_s.freeze, sorted)
|
|
33
|
+
end.freeze
|
|
34
|
+
new(Date.iso8601(date.to_s), sections, line_stats == true)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def total_prs
|
|
38
|
+
repositories.sum { |section| section.pull_requests.length }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def total_additions
|
|
42
|
+
stat_total(:additions)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def total_deletions
|
|
46
|
+
stat_total(:deletions)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def total_commits
|
|
50
|
+
stat_total(:commits)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def stat_total(name)
|
|
56
|
+
return nil unless line_stats
|
|
57
|
+
repositories.sum { |section| section.pull_requests.sum { |pull| pull.public_send(name) } }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octokit"
|
|
4
|
+
require "set"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Prdigest
|
|
8
|
+
class GitHub
|
|
9
|
+
SEARCH_CAP = 1_000
|
|
10
|
+
MAX_ATTEMPTS = 3
|
|
11
|
+
|
|
12
|
+
def initialize(token:, client: nil, sleeper: ->(seconds) { sleep(seconds) }, now: -> { Time.now.to_i })
|
|
13
|
+
@token = token.to_s
|
|
14
|
+
@client = client || default_client
|
|
15
|
+
@sleeper = sleeper
|
|
16
|
+
@now = now
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(date:, window:, repositories:, line_stats: false)
|
|
20
|
+
pulls = repositories.flat_map do |repository|
|
|
21
|
+
fetch_repository(repository, date, window, line_stats)
|
|
22
|
+
end
|
|
23
|
+
DayDigest.build(date: date, repository_order: repositories, pulls: pulls, line_stats: line_stats)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"#<#{self.class} token=[REDACTED]>"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
alias to_s inspect
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def default_client
|
|
35
|
+
middleware = Octokit::Default::MIDDLEWARE.dup
|
|
36
|
+
middleware.handlers.reject! { |handler| retry_middleware?(handler.klass) }
|
|
37
|
+
Octokit::Client.new(
|
|
38
|
+
access_token: @token,
|
|
39
|
+
middleware: middleware,
|
|
40
|
+
connection_options: { request: { open_timeout: 10, timeout: 30, write_timeout: 30 } }
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def retry_middleware?(middleware)
|
|
45
|
+
%w[Faraday::Request::Retry Faraday::Retry::Middleware].include?(middleware.name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch_repository(repository, date, window, line_stats)
|
|
49
|
+
return [] if window.zero_length?
|
|
50
|
+
|
|
51
|
+
query = build_query(repository, window)
|
|
52
|
+
page = 1
|
|
53
|
+
items = []
|
|
54
|
+
seen_numbers = Set.new
|
|
55
|
+
total = nil
|
|
56
|
+
loop do
|
|
57
|
+
response = request(repository, date) { @client.search_issues(query, per_page: 100, page: page) }
|
|
58
|
+
incomplete = field(response, :incomplete_results)
|
|
59
|
+
fail_fetch!(repository, date, "incomplete search results") if incomplete
|
|
60
|
+
response_total = Integer(field(response, :total_count))
|
|
61
|
+
fail_fetch!(repository, date, "search result cap exceeded") if response_total > SEARCH_CAP
|
|
62
|
+
total ||= response_total
|
|
63
|
+
fail_fetch!(repository, date, "search total changed during pagination") unless total == response_total
|
|
64
|
+
|
|
65
|
+
page_items = Array(field(response, :items))
|
|
66
|
+
page_items.each do |item|
|
|
67
|
+
number = Integer(field(item, :number))
|
|
68
|
+
fail_fetch!(repository, date, "pagination repeated a pull request") unless seen_numbers.add?(number)
|
|
69
|
+
end
|
|
70
|
+
items.concat(page_items)
|
|
71
|
+
fail_fetch!(repository, date, "pagination exceeded total") if items.length > total
|
|
72
|
+
break if items.length == total
|
|
73
|
+
fail_fetch!(repository, date, "pagination stopped before total") if page_items.empty?
|
|
74
|
+
page += 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
mapped = items.map { |item| map_item(item, repository, date, window) }
|
|
78
|
+
return mapped unless line_stats
|
|
79
|
+
|
|
80
|
+
mapped.map do |pull|
|
|
81
|
+
detail = request(repository, date) { @client.pull_request(repository, pull.number) }
|
|
82
|
+
PullRequest.new(
|
|
83
|
+
**pull.to_h,
|
|
84
|
+
additions: Integer(field(detail, :additions)),
|
|
85
|
+
deletions: Integer(field(detail, :deletions)),
|
|
86
|
+
commits: Integer(field(detail, :commits))
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
rescue FetchError
|
|
90
|
+
raise
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
fail_fetch!(repository, date, "malformed GitHub response (#{e.class})")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_query(repository, window)
|
|
96
|
+
finish = window.end_time - 1
|
|
97
|
+
"repo:#{repository} is:pr is:merged merged:#{timestamp(window.start_time)}..#{timestamp(finish)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def timestamp(time)
|
|
101
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def map_item(item, repository, date, window)
|
|
105
|
+
actual_repository = repository_name(item)
|
|
106
|
+
merged_at = Time.iso8601(field(field(item, :pull_request), :merged_at).to_s).utc
|
|
107
|
+
unless actual_repository.casecmp?(repository) && window.cover?(merged_at)
|
|
108
|
+
fail_fetch!(repository, date, "result outside requested repository or UTC window")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
PullRequest.new(
|
|
112
|
+
repository: repository,
|
|
113
|
+
number: Integer(field(item, :number)),
|
|
114
|
+
title: field(item, :title),
|
|
115
|
+
url: field(item, :html_url),
|
|
116
|
+
author: field(field(item, :user), :login),
|
|
117
|
+
merged_at: merged_at
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def repository_name(item)
|
|
122
|
+
repository = optional_field(item, :repository)
|
|
123
|
+
full_name = optional_field(repository, :full_name) if repository
|
|
124
|
+
return full_name.to_s unless full_name.to_s.empty?
|
|
125
|
+
|
|
126
|
+
url = field(item, :repository_url).to_s
|
|
127
|
+
match = url.match(%r{/repos/([^/]+/[^/]+)\z})
|
|
128
|
+
raise KeyError, "repository identity missing" unless match
|
|
129
|
+
match[1]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def field(object, key)
|
|
133
|
+
value = optional_field(object, key)
|
|
134
|
+
raise KeyError, key if value.nil?
|
|
135
|
+
value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def optional_field(object, key)
|
|
139
|
+
if object.respond_to?(:key?)
|
|
140
|
+
return object[key] if object.key?(key)
|
|
141
|
+
return object[key.to_s] if object.key?(key.to_s)
|
|
142
|
+
end
|
|
143
|
+
return object.public_send(key) if object.respond_to?(key)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def request(repository, date)
|
|
147
|
+
attempts = 0
|
|
148
|
+
begin
|
|
149
|
+
attempts += 1
|
|
150
|
+
yield
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
unless transient?(e) && attempts < MAX_ATTEMPTS
|
|
153
|
+
fail_fetch!(repository, date, error_kind(e))
|
|
154
|
+
end
|
|
155
|
+
delay = retry_delay(e) || attempts
|
|
156
|
+
fail_fetch!(repository, date, "retry delay exceeds 60 seconds") if delay > 60
|
|
157
|
+
@sleeper.call(delay)
|
|
158
|
+
retry
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def transient?(error)
|
|
163
|
+
error.is_a?(Octokit::ServerError) ||
|
|
164
|
+
rate_limited?(error) ||
|
|
165
|
+
(defined?(Faraday::ConnectionFailed) && error.is_a?(Faraday::ConnectionFailed)) ||
|
|
166
|
+
(defined?(Faraday::TimeoutError) && error.is_a?(Faraday::TimeoutError))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def rate_limited?(error)
|
|
170
|
+
error.is_a?(Octokit::TooManyRequests) ||
|
|
171
|
+
(error.respond_to?(:response_status) && Integer(error.response_status, exception: false) == 429)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def retry_delay(error)
|
|
175
|
+
headers = error.respond_to?(:response_headers) ? error.response_headers : nil
|
|
176
|
+
retry_after = Integer(response_header(headers, "retry-after"), exception: false)
|
|
177
|
+
return retry_after if retry_after
|
|
178
|
+
|
|
179
|
+
reset_at = Integer(response_header(headers, "x-ratelimit-reset"), exception: false)
|
|
180
|
+
[reset_at - Integer(@now.call), 0].max if reset_at
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def response_header(headers, name)
|
|
186
|
+
return unless headers
|
|
187
|
+
|
|
188
|
+
Faraday::Utils::Headers.from(headers)[name]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def error_kind(error)
|
|
192
|
+
transient?(error) ? "GitHub request retries exhausted" : "GitHub request refused"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def fail_fetch!(repository, date, reason)
|
|
196
|
+
raise FetchError.new("GitHub fetch failed for #{repository} on #{date}: #{reason}", kind: "github")
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|