hiiro 0.1.278 → 0.1.279
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/CHANGELOG.md +1 -1
- data/bin/h-app +16 -0
- data/bin/h-pr +2 -605
- data/bin/h-project +29 -0
- data/lib/hiiro/pinned_pr_manager.rb +604 -0
- data/lib/hiiro/version.rb +1 -1
- data/lib/hiiro.rb +1 -0
- data/script/update +9 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dac54cf95b87a2dd963a261922d9d269597970236714ace84586538ee38b643c
|
|
4
|
+
data.tar.gz: '0408a01c35a5888a2ff78d880085278b719007aa47ed40257749c65410225592'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74324fbd4fcfdb860270388df1a19f1affff4030d1d0a2c88362c9c3d26f1a1af7170a9eae2303b8467b2a60f6492166cdb2a0f69ba79adbe58dfcc8378ea005
|
|
7
|
+
data.tar.gz: b6d351fd166a8b694c067d83ffba0f63681f37c08565ebd3d24eaa3c2bad7d2a598327b54a47eb49f3ccc04d658b0be12cfd52fae4574a2fcfc712f24742cc8b
|
data/CHANGELOG.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Done.
|
|
1
|
+
Done. CHANGELOG.md has been updated with v0.1.279 for 2026-03-25. The new section documents the refactoring of `Hiiro::PinnedPRManager` extraction to `lib/hiiro/pinned_pr_manager.rb`, which is the only meaningful code change in the recent commits (the others were script updates and documentation restoration).
|
data/bin/h-app
CHANGED
|
@@ -251,6 +251,22 @@ Hiiro.run(*ARGV) {
|
|
|
251
251
|
exec('vim', *args)
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
add_subcmd(:sh) { |app_name=nil, *args|
|
|
255
|
+
root = git.root || task_root
|
|
256
|
+
unless root
|
|
257
|
+
puts "Not in a git repo or task - cannot resolve path"
|
|
258
|
+
next
|
|
259
|
+
end
|
|
260
|
+
result = environment.app_matcher.find(app_name.to_s)
|
|
261
|
+
unless result.match?
|
|
262
|
+
puts "App '#{app_name}' not found"
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
app_path = File.join(root, result.first.item.relative_path)
|
|
266
|
+
Dir.chdir(app_path)
|
|
267
|
+
args.empty? ? exec(ENV['SHELL'] || 'zsh') : exec(*args)
|
|
268
|
+
}
|
|
269
|
+
|
|
254
270
|
add_subcmd(:service) do |*svc_args|
|
|
255
271
|
sm = Hiiro::ServiceManager.new
|
|
256
272
|
Hiiro::ServiceManager.build_hiiro(this, sm).run
|
data/bin/h-pr
CHANGED
|
@@ -7,573 +7,6 @@ require "yaml"
|
|
|
7
7
|
require "json"
|
|
8
8
|
require "tempfile"
|
|
9
9
|
|
|
10
|
-
class PinnedPRManager
|
|
11
|
-
def pr_repo(pr)
|
|
12
|
-
pr.repo || Hiiro::Git::Pr.repo_from_url(pr.url)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def initialize
|
|
16
|
-
ensure_file
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def ensure_file
|
|
20
|
-
dir = File.dirname(Hiiro::Git::Pr::PINNED_FILE)
|
|
21
|
-
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
22
|
-
File.write(Hiiro::Git::Pr::PINNED_FILE, [].to_yaml) unless File.exist?(Hiiro::Git::Pr::PINNED_FILE)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def load_pinned
|
|
26
|
-
ensure_file
|
|
27
|
-
prs = Hiiro::Git::Pr.pinned_prs
|
|
28
|
-
if prs.any? { |p| p.slot.nil? }
|
|
29
|
-
next_slot = prs.map { |p| p.slot.to_i }.max.to_i
|
|
30
|
-
prs.each do |p|
|
|
31
|
-
next if p.slot
|
|
32
|
-
next_slot += 1
|
|
33
|
-
p.slot = next_slot
|
|
34
|
-
end
|
|
35
|
-
save_pinned(prs)
|
|
36
|
-
end
|
|
37
|
-
prs
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def save_pinned(prs)
|
|
41
|
-
ensure_file
|
|
42
|
-
File.write(Hiiro::Git::Pr::PINNED_FILE, prs.map(&:to_pinned_h).to_yaml)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def pin(pr)
|
|
46
|
-
pr.repo ||= Hiiro::Git::Pr.repo_from_url(pr.url)
|
|
47
|
-
|
|
48
|
-
pinned = load_pinned
|
|
49
|
-
existing = pinned.find { |p|
|
|
50
|
-
p.number == pr.number && pr_repo(p) == pr_repo(pr)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if existing
|
|
54
|
-
existing.title = pr.title unless pr.title.nil?
|
|
55
|
-
existing.state = pr.state unless pr.state.nil?
|
|
56
|
-
existing.url = pr.url unless pr.url.nil?
|
|
57
|
-
existing.head_branch = pr.head_branch unless pr.head_branch.nil?
|
|
58
|
-
existing.repo = pr.repo unless pr.repo.nil?
|
|
59
|
-
existing.is_draft = pr.is_draft unless pr.is_draft.nil?
|
|
60
|
-
existing.mergeable = pr.mergeable unless pr.mergeable.nil?
|
|
61
|
-
existing.review_decision = pr.review_decision unless pr.review_decision.nil?
|
|
62
|
-
existing.check_runs = pr.check_runs unless pr.check_runs.nil?
|
|
63
|
-
existing.checks = pr.checks unless pr.checks.nil?
|
|
64
|
-
existing.reviews = pr.reviews unless pr.reviews.nil?
|
|
65
|
-
existing.task = pr.task unless pr.task.nil?
|
|
66
|
-
existing.worktree = pr.worktree unless pr.worktree.nil?
|
|
67
|
-
existing.tmux_session = pr.tmux_session unless pr.tmux_session.nil?
|
|
68
|
-
existing.tags = pr.tags unless pr.tags.nil?
|
|
69
|
-
existing.assigned = pr.assigned unless pr.assigned.nil?
|
|
70
|
-
existing.authored = pr.authored unless pr.authored.nil?
|
|
71
|
-
existing.updated_at = Time.now.iso8601
|
|
72
|
-
else
|
|
73
|
-
pr.slot = (pinned.map { |p| p.slot.to_i }.max.to_i) + 1
|
|
74
|
-
pr.pinned_at = Time.now.iso8601
|
|
75
|
-
pinned << pr
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
save_pinned(pinned)
|
|
79
|
-
pr
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def unpin(pr_number)
|
|
83
|
-
pinned = load_pinned
|
|
84
|
-
removed = pinned.reject! { |p| p.number.to_s == pr_number.to_s }
|
|
85
|
-
save_pinned(pinned)
|
|
86
|
-
removed
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def pinned?(pr_number)
|
|
90
|
-
load_pinned.any? { |p| p.number.to_s == pr_number.to_s }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def fetch_pr_info(pr_number, repo: nil)
|
|
94
|
-
fields = 'number,title,url,headRefName,state,statusCheckRollup,reviewDecision,reviews,isDraft,mergeable'
|
|
95
|
-
repo_flag = repo ? " --repo #{repo}" : ""
|
|
96
|
-
output = `gh pr view #{pr_number}#{repo_flag} --json #{fields} 2>/dev/null`.strip
|
|
97
|
-
return nil if output.empty?
|
|
98
|
-
Hiiro::Git::Pr.from_gh_json(JSON.parse(output))
|
|
99
|
-
rescue JSON::ParserError
|
|
100
|
-
nil
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def fetch_current_branch_pr
|
|
104
|
-
fields = 'number,title,url,headRefName,state'
|
|
105
|
-
output = `gh pr view --json #{fields} 2>/dev/null`.strip
|
|
106
|
-
return nil if output.empty?
|
|
107
|
-
Hiiro::Git::Pr.from_gh_json(JSON.parse(output))
|
|
108
|
-
rescue JSON::ParserError
|
|
109
|
-
nil
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def fetch_my_prs
|
|
113
|
-
output = `gh pr list --author @me --state open --json number,title,headRefName,url 2>/dev/null`.strip
|
|
114
|
-
return [] if output.empty?
|
|
115
|
-
prs = JSON.parse(output) rescue []
|
|
116
|
-
prs.map { |data| Hiiro::Git::Pr.from_gh_json(data) }
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def fetch_assigned_prs
|
|
120
|
-
output = `gh pr list --assignee @me --state open --json number,title,headRefName,url 2>/dev/null`.strip
|
|
121
|
-
return [] if output.empty?
|
|
122
|
-
prs = JSON.parse(output) rescue []
|
|
123
|
-
prs.map { |data| Hiiro::Git::Pr.from_gh_json(data) }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def fetch_my_and_assigned_prs
|
|
127
|
-
authored_prs = fetch_my_prs
|
|
128
|
-
assigned_prs = fetch_assigned_prs
|
|
129
|
-
|
|
130
|
-
authored_numbers = authored_prs.map(&:number).to_set
|
|
131
|
-
assigned_prs.each { |pr| pr.assigned = true unless authored_numbers.include?(pr.number) }
|
|
132
|
-
authored_prs.each { |pr| pr.authored = true }
|
|
133
|
-
|
|
134
|
-
(authored_prs + assigned_prs).uniq(&:number)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def needs_refresh?(pr, force: false)
|
|
138
|
-
return true if force
|
|
139
|
-
return true unless pr.last_checked
|
|
140
|
-
|
|
141
|
-
last_check_time = Time.parse(pr.last_checked) rescue nil
|
|
142
|
-
return true unless last_check_time
|
|
143
|
-
|
|
144
|
-
# Refresh if last check was more than 2 minutes ago
|
|
145
|
-
(Time.now - last_check_time) > 120
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def code_freeze_active?
|
|
149
|
-
return @code_freeze_active unless @code_freeze_active.nil?
|
|
150
|
-
|
|
151
|
-
output = `isc codefreeze list 2>/dev/null`.strip
|
|
152
|
-
return @code_freeze_active = false if output.empty?
|
|
153
|
-
|
|
154
|
-
now = Time.now
|
|
155
|
-
@code_freeze_active = output.lines.drop(1).any? do |line|
|
|
156
|
-
parts = line.strip.split(/\s{2,}/)
|
|
157
|
-
next false if parts.length < 2
|
|
158
|
-
|
|
159
|
-
start_time = Time.parse(parts[0]) rescue nil
|
|
160
|
-
end_time = Time.parse(parts[1]) rescue nil
|
|
161
|
-
next false unless start_time && end_time
|
|
162
|
-
|
|
163
|
-
now >= start_time && now <= end_time
|
|
164
|
-
end
|
|
165
|
-
rescue
|
|
166
|
-
@code_freeze_active = false
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
# Overrides the ISC code freeze StatusContext in a raw statusCheckRollup array
|
|
170
|
-
# to reflect the actual current freeze state rather than GitHub's cached value.
|
|
171
|
-
def apply_isc_code_freeze_override!(rollup, frozen)
|
|
172
|
-
return unless rollup.is_a?(Array)
|
|
173
|
-
|
|
174
|
-
rollup.each do |ctx|
|
|
175
|
-
next unless ctx['__typename'] == 'StatusContext' && ctx['context'] == 'ISC code freeze'
|
|
176
|
-
ctx['state'] = frozen ? 'FAILURE' : 'SUCCESS'
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
# Accepts an array of PR records (each with 'number' and optionally 'repo'/'url'),
|
|
182
|
-
# groups them by repo, and fetches in batches per repo via GraphQL.
|
|
183
|
-
def batch_fetch_pr_info(prs)
|
|
184
|
-
return {} if prs.empty?
|
|
185
|
-
|
|
186
|
-
by_repo = prs.group_by { |pr| pr_repo(pr) || 'instacart/carrot' }
|
|
187
|
-
|
|
188
|
-
result = {}
|
|
189
|
-
by_repo.each do |repo_path, repo_prs|
|
|
190
|
-
owner, name = repo_path.split('/', 2)
|
|
191
|
-
pr_numbers = repo_prs.map(&:number)
|
|
192
|
-
result.merge!(fetch_batch_for_repo(owner, name, pr_numbers))
|
|
193
|
-
end
|
|
194
|
-
result
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
private
|
|
198
|
-
|
|
199
|
-
def fetch_batch_for_repo(owner, name, pr_numbers)
|
|
200
|
-
return {} if pr_numbers.empty?
|
|
201
|
-
|
|
202
|
-
pr_queries = pr_numbers.map.with_index do |num, idx|
|
|
203
|
-
<<~GRAPHQL.strip
|
|
204
|
-
pr#{idx}: pullRequest(number: #{num}) {
|
|
205
|
-
number
|
|
206
|
-
title
|
|
207
|
-
url
|
|
208
|
-
headRefName
|
|
209
|
-
state
|
|
210
|
-
isDraft
|
|
211
|
-
mergeable
|
|
212
|
-
reviewDecision
|
|
213
|
-
statusCheckRollup {
|
|
214
|
-
contexts(last: 100) {
|
|
215
|
-
nodes {
|
|
216
|
-
... on CheckRun {
|
|
217
|
-
__typename
|
|
218
|
-
name
|
|
219
|
-
conclusion
|
|
220
|
-
status
|
|
221
|
-
detailsUrl
|
|
222
|
-
}
|
|
223
|
-
... on StatusContext {
|
|
224
|
-
__typename
|
|
225
|
-
context
|
|
226
|
-
state
|
|
227
|
-
targetUrl
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
reviews(last: 50) {
|
|
233
|
-
nodes {
|
|
234
|
-
author {
|
|
235
|
-
login
|
|
236
|
-
}
|
|
237
|
-
state
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
GRAPHQL
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
query = <<~GRAPHQL
|
|
245
|
-
query {
|
|
246
|
-
repository(owner: "#{owner}", name: "#{name}") {
|
|
247
|
-
#{pr_queries.join("\n")}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
GRAPHQL
|
|
251
|
-
|
|
252
|
-
result = `gh api graphql -f query='#{query.gsub("'", "'\\''")}' 2>/dev/null`
|
|
253
|
-
return {} if result.empty?
|
|
254
|
-
|
|
255
|
-
data = JSON.parse(result)
|
|
256
|
-
repo_data = data.dig('data', 'repository')
|
|
257
|
-
return {} unless repo_data
|
|
258
|
-
|
|
259
|
-
pr_info_by_key = {}
|
|
260
|
-
repo_path = "#{owner}/#{name}"
|
|
261
|
-
pr_numbers.each_with_index do |num, idx|
|
|
262
|
-
pr_data = repo_data["pr#{idx}"]
|
|
263
|
-
next unless pr_data
|
|
264
|
-
|
|
265
|
-
pr_info_by_key[[num, repo_path]] = {
|
|
266
|
-
'number' => pr_data['number'],
|
|
267
|
-
'title' => pr_data['title'],
|
|
268
|
-
'url' => pr_data['url'],
|
|
269
|
-
'headRefName' => pr_data['headRefName'],
|
|
270
|
-
'state' => pr_data['state'],
|
|
271
|
-
'isDraft' => pr_data['isDraft'],
|
|
272
|
-
'mergeable' => pr_data['mergeable'],
|
|
273
|
-
'reviewDecision' => pr_data['reviewDecision'],
|
|
274
|
-
'statusCheckRollup' => pr_data.dig('statusCheckRollup', 'contexts', 'nodes'),
|
|
275
|
-
'reviews' => pr_data.dig('reviews', 'nodes') || [],
|
|
276
|
-
'repo' => repo_path
|
|
277
|
-
}
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
pr_info_by_key
|
|
281
|
-
rescue JSON::ParserError, StandardError
|
|
282
|
-
{}
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
public
|
|
286
|
-
|
|
287
|
-
def refresh_all_status(prs, force: false)
|
|
288
|
-
prs_to_refresh = prs.select { |pr| needs_refresh?(pr, force: force) }
|
|
289
|
-
|
|
290
|
-
if prs_to_refresh.empty?
|
|
291
|
-
puts "All PRs recently checked (within last 2 minutes). Use -U to force update." unless force
|
|
292
|
-
return prs
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# infos is keyed by [number, repo] to avoid collisions across repos
|
|
296
|
-
infos = batch_fetch_pr_info(prs_to_refresh)
|
|
297
|
-
frozen = code_freeze_active?
|
|
298
|
-
|
|
299
|
-
prs_to_refresh.each do |pr|
|
|
300
|
-
info = infos[[pr.number, pr_repo(pr) || 'instacart/carrot']]
|
|
301
|
-
next unless info
|
|
302
|
-
|
|
303
|
-
rollup = info['statusCheckRollup']
|
|
304
|
-
apply_isc_code_freeze_override!(rollup, frozen)
|
|
305
|
-
|
|
306
|
-
pr.state = info['state']
|
|
307
|
-
pr.title = info['title']
|
|
308
|
-
pr.check_runs = rollup
|
|
309
|
-
pr.checks = Hiiro::Git::Pr.summarize_checks(rollup)
|
|
310
|
-
pr.reviews = Hiiro::Git::Pr.summarize_reviews(info['reviews'])
|
|
311
|
-
pr.review_decision = info['reviewDecision']
|
|
312
|
-
pr.is_draft = info['isDraft']
|
|
313
|
-
pr.mergeable = info['mergeable']
|
|
314
|
-
pr.last_checked = Time.now.iso8601
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
prs
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def refresh_status(pr, force: false)
|
|
321
|
-
return pr unless needs_refresh?(pr, force: force)
|
|
322
|
-
|
|
323
|
-
info = fetch_pr_info(pr.number, repo: pr_repo(pr))
|
|
324
|
-
return pr unless info
|
|
325
|
-
|
|
326
|
-
rollup = info.check_runs
|
|
327
|
-
apply_isc_code_freeze_override!(rollup, code_freeze_active?)
|
|
328
|
-
|
|
329
|
-
pr.state = info.state
|
|
330
|
-
pr.title = info.title
|
|
331
|
-
pr.check_runs = rollup
|
|
332
|
-
pr.checks = Hiiro::Git::Pr.summarize_checks(rollup)
|
|
333
|
-
pr.reviews = info.reviews
|
|
334
|
-
pr.review_decision = info.review_decision
|
|
335
|
-
pr.is_draft = info.is_draft
|
|
336
|
-
pr.mergeable = info.mergeable
|
|
337
|
-
pr.last_checked = Time.now.iso8601
|
|
338
|
-
pr
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
FILTER_PREDICATES = {
|
|
342
|
-
red: ->(pr) { (c = pr.checks) && c['failed'].to_i > 0 },
|
|
343
|
-
green: ->(pr) { (c = pr.checks) && c['failed'].to_i == 0 && c['pending'].to_i == 0 && c['success'].to_i > 0 },
|
|
344
|
-
conflicts: ->(pr) { pr.conflicting? },
|
|
345
|
-
drafts: ->(pr) { pr.draft? },
|
|
346
|
-
pending: ->(pr) { (c = pr.checks) && c['pending'].to_i > 0 && c['failed'].to_i == 0 },
|
|
347
|
-
merged: ->(pr) { pr.merged? },
|
|
348
|
-
active: ->(pr) { !pr.merged? && !pr.closed? },
|
|
349
|
-
}.freeze
|
|
350
|
-
|
|
351
|
-
def display_pinned(pr, idx = nil, widths: {}, oneline: false)
|
|
352
|
-
slot_w = widths[:slot] || 1
|
|
353
|
-
succ_w = widths[:succ] || 1
|
|
354
|
-
total_w = widths[:total] || 1
|
|
355
|
-
as_w = widths[:as] || 1
|
|
356
|
-
crs_w = widths[:crs] || 1
|
|
357
|
-
|
|
358
|
-
slot_num = (pr.slot || (idx ? idx + 1 : 1)).to_s
|
|
359
|
-
num = "#{slot_num.rjust(slot_w)}."
|
|
360
|
-
indent = " " * (slot_w + 2)
|
|
361
|
-
|
|
362
|
-
check_emoji, checks_count_str =
|
|
363
|
-
if pr.checks
|
|
364
|
-
c = pr.checks
|
|
365
|
-
has_failed = c['failed'].to_i > 0
|
|
366
|
-
has_pending = c['pending'].to_i > 0
|
|
367
|
-
only_frozen = has_failed && c['failed'].to_i == c['frozen'].to_i
|
|
368
|
-
emoji = if has_failed && has_pending
|
|
369
|
-
only_frozen ? "⏳❄️" : "⏳❌"
|
|
370
|
-
elsif has_failed
|
|
371
|
-
only_frozen ? " ❄️" : " ❌"
|
|
372
|
-
elsif has_pending
|
|
373
|
-
"⏳ "
|
|
374
|
-
else
|
|
375
|
-
" ✅"
|
|
376
|
-
end
|
|
377
|
-
succ = c['success'].to_i.to_s.rjust(succ_w)
|
|
378
|
-
total = c['total'].to_i.to_s.rjust(total_w)
|
|
379
|
-
[emoji, "#{succ}/#{total}"]
|
|
380
|
-
else
|
|
381
|
-
["", nil]
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
state_label = case pr.state
|
|
385
|
-
when 'MERGED' then 'M'
|
|
386
|
-
when 'CLOSED' then 'X'
|
|
387
|
-
else pr.draft? ? 'd' : 'o'
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
bracket_parts = [state_label, check_emoji, checks_count_str].reject { |p| p.nil? || p.empty? }
|
|
391
|
-
state_icon = "[#{bracket_parts.join(' ')}]"
|
|
392
|
-
|
|
393
|
-
r = pr.reviews || {}
|
|
394
|
-
as = r['approved'].to_i
|
|
395
|
-
crs = r['changes_requested'].to_i
|
|
396
|
-
|
|
397
|
-
as_val = as > 0 ? as.to_s : '-'
|
|
398
|
-
crs_val = crs > 0 ? crs.to_s : '-'
|
|
399
|
-
as_colored = as > 0 ? "\e[30;102m#{as_val}\e[0m" : as_val
|
|
400
|
-
crs_colored = crs > 0 ? "\e[30;103m#{crs_val}\e[0m" : crs_val
|
|
401
|
-
as_pad = " " * [as_w - as_val.length, 0].max
|
|
402
|
-
crs_pad = " " * [crs_w - crs_val.length, 0].max
|
|
403
|
-
conflict_str = pr.conflicting? ? " \e[30;101mC\e[0m" : ""
|
|
404
|
-
reviews_str = "#{as_pad}#{as_colored}a/#{crs_pad}#{crs_colored}cr#{conflict_str}"
|
|
405
|
-
|
|
406
|
-
repo = pr_repo(pr)
|
|
407
|
-
repo_label = (repo && repo != 'instacart/carrot') ? "[#{repo}] " : ""
|
|
408
|
-
|
|
409
|
-
tags = Array(pr.tags)
|
|
410
|
-
tags_str = tags.any? ? tags.map { |t| "\e[30;104m#{t}\e[0m" }.join(' ') : nil
|
|
411
|
-
|
|
412
|
-
branch_str = pr.head_branch ? " \e[90m#{pr.head_branch}\e[0m" : ""
|
|
413
|
-
title_str = "\e[1m#{pr.title}\e[0m"
|
|
414
|
-
line1 = "#{num} ##{pr.number} #{state_icon} #{reviews_str}#{branch_str}"
|
|
415
|
-
line2 = "#{indent}#{repo_label}#{title_str}"
|
|
416
|
-
line3 = pr.url ? "#{indent}#{pr.url}" : nil
|
|
417
|
-
line4 = tags_str ? "#{indent}#{tags_str}" : nil
|
|
418
|
-
|
|
419
|
-
if oneline
|
|
420
|
-
"#{line1} #{repo_label}#{title_str}#{tags_str ? " #{tags_str}" : ""}"
|
|
421
|
-
else
|
|
422
|
-
[line1, line2, line3, line4].compact.join("\n")
|
|
423
|
-
end
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def filter_active?(opts)
|
|
427
|
-
FILTER_PREDICATES.keys.any? { |f| opts.respond_to?(f) && opts.send(f) } ||
|
|
428
|
-
(opts.respond_to?(:tag) && Array(opts.tag).any?)
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
def apply_filters(prs, opts, forced: [])
|
|
432
|
-
active = FILTER_PREDICATES.keys.select { |f| opts.respond_to?(f) && opts.send(f) }
|
|
433
|
-
active = (active + forced).uniq
|
|
434
|
-
|
|
435
|
-
results = active.empty? ? prs : prs.select { |pr| active.any? { |f| FILTER_PREDICATES[f]&.call(pr) } }
|
|
436
|
-
|
|
437
|
-
# Tags are an AND post-filter; multiple tags are OR'd among themselves
|
|
438
|
-
tag_filter = Array(opts.respond_to?(:tag) ? opts.tag : nil).map(&:to_s).reject(&:empty?)
|
|
439
|
-
unless tag_filter.empty?
|
|
440
|
-
results = results.select { |pr| (Array(pr.tags) & tag_filter).any? }
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
results
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
def display_detailed(pr, idx = nil)
|
|
447
|
-
lines = []
|
|
448
|
-
num = idx ? "#{idx + 1}." : ""
|
|
449
|
-
|
|
450
|
-
state_str = case pr.state
|
|
451
|
-
when 'MERGED' then 'MERGED'
|
|
452
|
-
when 'CLOSED' then 'CLOSED'
|
|
453
|
-
else pr.draft? ? 'DRAFT' : 'OPEN'
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
repo = pr_repo(pr)
|
|
457
|
-
repo_label = (repo && repo != 'instacart/carrot') ? " [#{repo}]" : ""
|
|
458
|
-
|
|
459
|
-
lines << "#{num} ##{pr.number}#{repo_label} - #{pr.title}"
|
|
460
|
-
lines << " State: #{state_str}"
|
|
461
|
-
lines << " Branch: #{pr.head_branch}" if pr.head_branch
|
|
462
|
-
lines << " URL: #{pr.url}" if pr.url
|
|
463
|
-
|
|
464
|
-
# Checks
|
|
465
|
-
if pr.checks
|
|
466
|
-
c = pr.checks
|
|
467
|
-
check_status = if c['failed'] > 0
|
|
468
|
-
"FAILING (#{c['success']}/#{c['total']} passed, #{c['failed']} failed)"
|
|
469
|
-
elsif c['pending'] > 0
|
|
470
|
-
"PENDING (#{c['success']}/#{c['total']} passed, #{c['pending']} pending)"
|
|
471
|
-
else
|
|
472
|
-
"PASSING (#{c['success']}/#{c['total']})"
|
|
473
|
-
end
|
|
474
|
-
lines << " Checks: #{check_status}"
|
|
475
|
-
else
|
|
476
|
-
lines << " Checks: (none)"
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
# Reviews
|
|
480
|
-
if pr.reviews
|
|
481
|
-
r = pr.reviews
|
|
482
|
-
review_parts = []
|
|
483
|
-
review_parts << "#{r['approved']} approved" if r['approved'] > 0
|
|
484
|
-
review_parts << "#{r['changes_requested']} requesting changes" if r['changes_requested'] > 0
|
|
485
|
-
review_parts << "#{r['commented']} commented" if r['commented'] > 0
|
|
486
|
-
|
|
487
|
-
if review_parts.any?
|
|
488
|
-
lines << " Reviews: #{review_parts.join(', ')}"
|
|
489
|
-
if r['reviewers'] && !r['reviewers'].empty?
|
|
490
|
-
r['reviewers'].each do |author, state|
|
|
491
|
-
icon = case state
|
|
492
|
-
when 'APPROVED' then '+'
|
|
493
|
-
when 'CHANGES_REQUESTED' then '-'
|
|
494
|
-
else '?'
|
|
495
|
-
end
|
|
496
|
-
lines << " #{icon} #{author}: #{state.downcase.gsub('_', ' ')}"
|
|
497
|
-
end
|
|
498
|
-
end
|
|
499
|
-
else
|
|
500
|
-
lines << " Reviews: (none)"
|
|
501
|
-
end
|
|
502
|
-
else
|
|
503
|
-
lines << " Reviews: (not fetched)"
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
lines << " Mergeable: #{pr.mergeable}" if pr.mergeable
|
|
507
|
-
|
|
508
|
-
lines.join("\n")
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
def pr_yaml_lines(prs = nil)
|
|
512
|
-
(prs || load_pinned).map do |pr|
|
|
513
|
-
branch = pr.head_branch ? "[#{pr.head_branch}]" : "[##{pr.number}]"
|
|
514
|
-
"- #{pr.number} # #{branch} #{pr.title}"
|
|
515
|
-
end
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
def strip_ansi(str)
|
|
519
|
-
str.gsub(/\e\[[0-9;]*m/, '')
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
def display_check_runs(pr, indent: " ")
|
|
523
|
-
runs = pr.check_runs
|
|
524
|
-
return unless runs.is_a?(Array) && runs.any?
|
|
525
|
-
|
|
526
|
-
runs.each do |run|
|
|
527
|
-
case run['__typename']
|
|
528
|
-
when 'CheckRun'
|
|
529
|
-
emoji = check_run_emoji(run['conclusion'], run['status'])
|
|
530
|
-
name = run['name'] || run['workflowName'] || '(unknown)'
|
|
531
|
-
url = run['detailsUrl']
|
|
532
|
-
when 'StatusContext'
|
|
533
|
-
emoji = status_context_emoji(run['state'])
|
|
534
|
-
name = run['context'] || '(unknown)'
|
|
535
|
-
url = run['targetUrl']
|
|
536
|
-
else
|
|
537
|
-
emoji = '?'
|
|
538
|
-
name = run['name'] || run['context'] || '(unknown)'
|
|
539
|
-
url = run['detailsUrl'] || run['targetUrl']
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
line = "#{indent}#{emoji} #{name}"
|
|
543
|
-
line += "\n#{indent} #{url}" if url
|
|
544
|
-
puts line
|
|
545
|
-
end
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
private
|
|
549
|
-
|
|
550
|
-
def check_run_emoji(conclusion, status)
|
|
551
|
-
return "⏳" if %w[QUEUED IN_PROGRESS PENDING REQUESTED WAITING].include?(status) && conclusion.nil?
|
|
552
|
-
case conclusion
|
|
553
|
-
when 'SUCCESS' then "✅"
|
|
554
|
-
when 'FAILURE', 'ERROR' then "❌"
|
|
555
|
-
when 'TIMED_OUT' then "⏰"
|
|
556
|
-
when 'CANCELLED' then "🚫"
|
|
557
|
-
when 'SKIPPED' then "⏭ "
|
|
558
|
-
when 'NEUTRAL' then "⚪"
|
|
559
|
-
when 'STARTUP_FAILURE' then "💥"
|
|
560
|
-
when 'ACTION_REQUIRED' then "⚠️ "
|
|
561
|
-
else "⏳"
|
|
562
|
-
end
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
def status_context_emoji(state)
|
|
566
|
-
case state
|
|
567
|
-
when 'SUCCESS' then "✅"
|
|
568
|
-
when 'FAILURE', 'ERROR' then "❌"
|
|
569
|
-
when 'PENDING' then "⏳"
|
|
570
|
-
else "❓"
|
|
571
|
-
end
|
|
572
|
-
end
|
|
573
|
-
|
|
574
|
-
public
|
|
575
|
-
end
|
|
576
|
-
|
|
577
10
|
FILTER_OPTS = Proc.new {
|
|
578
11
|
flag(:red, short: 'r', desc: 'filter: failing checks')
|
|
579
12
|
flag(:green, short: 'g', desc: 'filter: passing checks')
|
|
@@ -587,44 +20,8 @@ FILTER_OPTS = Proc.new {
|
|
|
587
20
|
}
|
|
588
21
|
|
|
589
22
|
Hiiro.run(*ARGV, plugins: [Pins]) do
|
|
590
|
-
pinned_manager = PinnedPRManager.new
|
|
591
|
-
|
|
592
|
-
add_resolver(:pr,
|
|
593
|
-
-> {
|
|
594
|
-
pinned = pinned_manager.load_pinned
|
|
595
|
-
if pinned.empty?
|
|
596
|
-
STDERR.puts "No tracked PRs. Use a PR number or 'h pr track' to track PRs."
|
|
597
|
-
nil
|
|
598
|
-
else
|
|
599
|
-
lines = pinned.each_with_index.each_with_object({}) do |(pr, idx), h|
|
|
600
|
-
h[pinned_manager.strip_ansi(pinned_manager.display_pinned(pr, idx, oneline: true))] = pr.number.to_s
|
|
601
|
-
end
|
|
602
|
-
fuzzyfind_from_map(lines)
|
|
603
|
-
end
|
|
604
|
-
}
|
|
605
|
-
) do |ref|
|
|
606
|
-
if (pin_value = pins.get(ref.to_s))
|
|
607
|
-
pin_value
|
|
608
|
-
else
|
|
609
|
-
pinned = pinned_manager.load_pinned
|
|
610
|
-
if pinned.any? { |pr| pr.number.to_s == ref.to_s }
|
|
611
|
-
ref.to_s
|
|
612
|
-
elsif ref.to_s =~ /^\d+$/ && ref.to_i > 0
|
|
613
|
-
slot = ref.to_i
|
|
614
|
-
by_slot = pinned.find { |p| p.slot.to_i == slot }
|
|
615
|
-
if by_slot
|
|
616
|
-
by_slot.number.to_s
|
|
617
|
-
elsif slot - 1 < pinned.length
|
|
618
|
-
# Fall back to 1-based index for unslotted data
|
|
619
|
-
pinned[slot - 1].number.to_s
|
|
620
|
-
else
|
|
621
|
-
ref.to_s
|
|
622
|
-
end
|
|
623
|
-
else
|
|
624
|
-
ref.to_s
|
|
625
|
-
end
|
|
626
|
-
end
|
|
627
|
-
end
|
|
23
|
+
pinned_manager = Hiiro::PinnedPRManager.new
|
|
24
|
+
Hiiro::PinnedPRManager.add_resolvers(self)
|
|
628
25
|
|
|
629
26
|
watch_block = ->(original_pr_number=nil, *watch_args) {
|
|
630
27
|
watch = get_value(:watch)
|
data/bin/h-project
CHANGED
|
@@ -209,6 +209,35 @@ Hiiro.run(*ARGV) do
|
|
|
209
209
|
HELP
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
# === SHELL IN PROJECT ===
|
|
213
|
+
add_subcmd(:sh) { |project_name=nil, *args|
|
|
214
|
+
if project_name.nil? || project_name.empty?
|
|
215
|
+
puts "Usage: h project sh <project_name> [command...]"
|
|
216
|
+
next
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
re = /#{project_name}/i
|
|
220
|
+
conf_matches = (projects_from_config || {}).select { |k, v| k.match?(re) }
|
|
221
|
+
dir_matches = (project_dirs || {}).select { |proj, path| proj.match?(re) }
|
|
222
|
+
matches = dir_matches.merge(conf_matches)
|
|
223
|
+
matches = matches.select { |name, path| name == project_name } if matches.count > 1
|
|
224
|
+
|
|
225
|
+
case matches.count
|
|
226
|
+
when 0
|
|
227
|
+
puts "Project '#{project_name}' not found"
|
|
228
|
+
puts
|
|
229
|
+
puts "Available projects:"
|
|
230
|
+
project_dirs.merge(projects_from_config).keys.sort.each { |k| puts " #{k}" }
|
|
231
|
+
when 1
|
|
232
|
+
_, path = matches.first
|
|
233
|
+
Dir.chdir(path)
|
|
234
|
+
args.empty? ? exec(ENV['SHELL'] || 'zsh') : exec(*args)
|
|
235
|
+
else
|
|
236
|
+
puts "Ambiguous project '#{project_name}' — multiple matches:"
|
|
237
|
+
matches.each { |name, path| puts format(" %-15s %s", name, path) }
|
|
238
|
+
end
|
|
239
|
+
}
|
|
240
|
+
|
|
212
241
|
add_default {
|
|
213
242
|
run_subcmd(:help)
|
|
214
243
|
}
|
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
class Hiiro
|
|
7
|
+
class PinnedPRManager
|
|
8
|
+
FILTER_PREDICATES = {
|
|
9
|
+
red: ->(pr) { (c = pr.checks) && c['failed'].to_i > 0 },
|
|
10
|
+
green: ->(pr) { (c = pr.checks) && c['failed'].to_i == 0 && c['pending'].to_i == 0 && c['success'].to_i > 0 },
|
|
11
|
+
conflicts: ->(pr) { pr.conflicting? },
|
|
12
|
+
drafts: ->(pr) { pr.draft? },
|
|
13
|
+
pending: ->(pr) { (c = pr.checks) && c['pending'].to_i > 0 && c['failed'].to_i == 0 },
|
|
14
|
+
merged: ->(pr) { pr.merged? },
|
|
15
|
+
active: ->(pr) { !pr.merged? && !pr.closed? },
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.add_resolvers(hiiro)
|
|
19
|
+
pm = new
|
|
20
|
+
hiiro.add_resolver(:pr,
|
|
21
|
+
-> {
|
|
22
|
+
pinned = pm.load_pinned
|
|
23
|
+
if pinned.empty?
|
|
24
|
+
STDERR.puts "No tracked PRs. Use a PR number or 'h pr track' to track PRs."
|
|
25
|
+
nil
|
|
26
|
+
else
|
|
27
|
+
lines = pinned.each_with_index.each_with_object({}) do |(pr, idx), h|
|
|
28
|
+
h[pm.strip_ansi(pm.display_pinned(pr, idx, oneline: true))] = pr.number.to_s
|
|
29
|
+
end
|
|
30
|
+
hiiro.fuzzyfind_from_map(lines)
|
|
31
|
+
end
|
|
32
|
+
}
|
|
33
|
+
) do |ref|
|
|
34
|
+
if (pin_value = hiiro.pins.get(ref.to_s))
|
|
35
|
+
pin_value
|
|
36
|
+
else
|
|
37
|
+
pinned = pm.load_pinned
|
|
38
|
+
if pinned.any? { |pr| pr.number.to_s == ref.to_s }
|
|
39
|
+
ref.to_s
|
|
40
|
+
elsif ref.to_s =~ /^\d+$/ && ref.to_i > 0
|
|
41
|
+
slot = ref.to_i
|
|
42
|
+
by_slot = pinned.find { |p| p.slot.to_i == slot }
|
|
43
|
+
if by_slot
|
|
44
|
+
by_slot.number.to_s
|
|
45
|
+
elsif slot - 1 < pinned.length
|
|
46
|
+
# Fall back to 1-based index for unslotted data
|
|
47
|
+
pinned[slot - 1].number.to_s
|
|
48
|
+
else
|
|
49
|
+
ref.to_s
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
ref.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def pr_repo(pr)
|
|
59
|
+
pr.repo || Hiiro::Git::Pr.repo_from_url(pr.url)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize
|
|
63
|
+
ensure_file
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ensure_file
|
|
67
|
+
dir = File.dirname(Hiiro::Git::Pr::PINNED_FILE)
|
|
68
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
69
|
+
File.write(Hiiro::Git::Pr::PINNED_FILE, [].to_yaml) unless File.exist?(Hiiro::Git::Pr::PINNED_FILE)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def load_pinned
|
|
73
|
+
ensure_file
|
|
74
|
+
prs = Hiiro::Git::Pr.pinned_prs
|
|
75
|
+
if prs.any? { |p| p.slot.nil? }
|
|
76
|
+
next_slot = prs.map { |p| p.slot.to_i }.max.to_i
|
|
77
|
+
prs.each do |p|
|
|
78
|
+
next if p.slot
|
|
79
|
+
next_slot += 1
|
|
80
|
+
p.slot = next_slot
|
|
81
|
+
end
|
|
82
|
+
save_pinned(prs)
|
|
83
|
+
end
|
|
84
|
+
prs
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def save_pinned(prs)
|
|
88
|
+
ensure_file
|
|
89
|
+
File.write(Hiiro::Git::Pr::PINNED_FILE, prs.map(&:to_pinned_h).to_yaml)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pin(pr)
|
|
93
|
+
pr.repo ||= Hiiro::Git::Pr.repo_from_url(pr.url)
|
|
94
|
+
|
|
95
|
+
pinned = load_pinned
|
|
96
|
+
existing = pinned.find { |p|
|
|
97
|
+
p.number == pr.number && pr_repo(p) == pr_repo(pr)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if existing
|
|
101
|
+
existing.title = pr.title unless pr.title.nil?
|
|
102
|
+
existing.state = pr.state unless pr.state.nil?
|
|
103
|
+
existing.url = pr.url unless pr.url.nil?
|
|
104
|
+
existing.head_branch = pr.head_branch unless pr.head_branch.nil?
|
|
105
|
+
existing.repo = pr.repo unless pr.repo.nil?
|
|
106
|
+
existing.is_draft = pr.is_draft unless pr.is_draft.nil?
|
|
107
|
+
existing.mergeable = pr.mergeable unless pr.mergeable.nil?
|
|
108
|
+
existing.review_decision = pr.review_decision unless pr.review_decision.nil?
|
|
109
|
+
existing.check_runs = pr.check_runs unless pr.check_runs.nil?
|
|
110
|
+
existing.checks = pr.checks unless pr.checks.nil?
|
|
111
|
+
existing.reviews = pr.reviews unless pr.reviews.nil?
|
|
112
|
+
existing.task = pr.task unless pr.task.nil?
|
|
113
|
+
existing.worktree = pr.worktree unless pr.worktree.nil?
|
|
114
|
+
existing.tmux_session = pr.tmux_session unless pr.tmux_session.nil?
|
|
115
|
+
existing.tags = pr.tags unless pr.tags.nil?
|
|
116
|
+
existing.assigned = pr.assigned unless pr.assigned.nil?
|
|
117
|
+
existing.authored = pr.authored unless pr.authored.nil?
|
|
118
|
+
existing.updated_at = Time.now.iso8601
|
|
119
|
+
else
|
|
120
|
+
pr.slot = (pinned.map { |p| p.slot.to_i }.max.to_i) + 1
|
|
121
|
+
pr.pinned_at = Time.now.iso8601
|
|
122
|
+
pinned << pr
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
save_pinned(pinned)
|
|
126
|
+
pr
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def unpin(pr_number)
|
|
130
|
+
pinned = load_pinned
|
|
131
|
+
removed = pinned.reject! { |p| p.number.to_s == pr_number.to_s }
|
|
132
|
+
save_pinned(pinned)
|
|
133
|
+
removed
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def pinned?(pr_number)
|
|
137
|
+
load_pinned.any? { |p| p.number.to_s == pr_number.to_s }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def fetch_pr_info(pr_number, repo: nil)
|
|
141
|
+
fields = 'number,title,url,headRefName,state,statusCheckRollup,reviewDecision,reviews,isDraft,mergeable'
|
|
142
|
+
repo_flag = repo ? " --repo #{repo}" : ""
|
|
143
|
+
output = `gh pr view #{pr_number}#{repo_flag} --json #{fields} 2>/dev/null`.strip
|
|
144
|
+
return nil if output.empty?
|
|
145
|
+
Hiiro::Git::Pr.from_gh_json(JSON.parse(output))
|
|
146
|
+
rescue JSON::ParserError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def fetch_current_branch_pr
|
|
151
|
+
fields = 'number,title,url,headRefName,state'
|
|
152
|
+
output = `gh pr view --json #{fields} 2>/dev/null`.strip
|
|
153
|
+
return nil if output.empty?
|
|
154
|
+
Hiiro::Git::Pr.from_gh_json(JSON.parse(output))
|
|
155
|
+
rescue JSON::ParserError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def fetch_my_prs
|
|
160
|
+
output = `gh pr list --author @me --state open --json number,title,headRefName,url 2>/dev/null`.strip
|
|
161
|
+
return [] if output.empty?
|
|
162
|
+
prs = JSON.parse(output) rescue []
|
|
163
|
+
prs.map { |data| Hiiro::Git::Pr.from_gh_json(data) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fetch_assigned_prs
|
|
167
|
+
output = `gh pr list --assignee @me --state open --json number,title,headRefName,url 2>/dev/null`.strip
|
|
168
|
+
return [] if output.empty?
|
|
169
|
+
prs = JSON.parse(output) rescue []
|
|
170
|
+
prs.map { |data| Hiiro::Git::Pr.from_gh_json(data) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def fetch_my_and_assigned_prs
|
|
174
|
+
authored_prs = fetch_my_prs
|
|
175
|
+
assigned_prs = fetch_assigned_prs
|
|
176
|
+
|
|
177
|
+
authored_numbers = authored_prs.map(&:number).to_set
|
|
178
|
+
assigned_prs.each { |pr| pr.assigned = true unless authored_numbers.include?(pr.number) }
|
|
179
|
+
authored_prs.each { |pr| pr.authored = true }
|
|
180
|
+
|
|
181
|
+
(authored_prs + assigned_prs).uniq(&:number)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def needs_refresh?(pr, force: false)
|
|
185
|
+
return true if force
|
|
186
|
+
return true unless pr.last_checked
|
|
187
|
+
|
|
188
|
+
last_check_time = Time.parse(pr.last_checked) rescue nil
|
|
189
|
+
return true unless last_check_time
|
|
190
|
+
|
|
191
|
+
# Refresh if last check was more than 2 minutes ago
|
|
192
|
+
(Time.now - last_check_time) > 120
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def code_freeze_active?
|
|
196
|
+
return @code_freeze_active unless @code_freeze_active.nil?
|
|
197
|
+
|
|
198
|
+
output = `isc codefreeze list 2>/dev/null`.strip
|
|
199
|
+
return @code_freeze_active = false if output.empty?
|
|
200
|
+
|
|
201
|
+
now = Time.now
|
|
202
|
+
@code_freeze_active = output.lines.drop(1).any? do |line|
|
|
203
|
+
parts = line.strip.split(/\s{2,}/)
|
|
204
|
+
next false if parts.length < 2
|
|
205
|
+
|
|
206
|
+
start_time = Time.parse(parts[0]) rescue nil
|
|
207
|
+
end_time = Time.parse(parts[1]) rescue nil
|
|
208
|
+
next false unless start_time && end_time
|
|
209
|
+
|
|
210
|
+
now >= start_time && now <= end_time
|
|
211
|
+
end
|
|
212
|
+
rescue
|
|
213
|
+
@code_freeze_active = false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Overrides the ISC code freeze StatusContext in a raw statusCheckRollup array
|
|
217
|
+
# to reflect the actual current freeze state rather than GitHub's cached value.
|
|
218
|
+
def apply_isc_code_freeze_override!(rollup, frozen)
|
|
219
|
+
return unless rollup.is_a?(Array)
|
|
220
|
+
|
|
221
|
+
rollup.each do |ctx|
|
|
222
|
+
next unless ctx['__typename'] == 'StatusContext' && ctx['context'] == 'ISC code freeze'
|
|
223
|
+
ctx['state'] = frozen ? 'FAILURE' : 'SUCCESS'
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Accepts an array of PR records (each with 'number' and optionally 'repo'/'url'),
|
|
228
|
+
# groups them by repo, and fetches in batches per repo via GraphQL.
|
|
229
|
+
def batch_fetch_pr_info(prs)
|
|
230
|
+
return {} if prs.empty?
|
|
231
|
+
|
|
232
|
+
by_repo = prs.group_by { |pr| pr_repo(pr) || 'instacart/carrot' }
|
|
233
|
+
|
|
234
|
+
result = {}
|
|
235
|
+
by_repo.each do |repo_path, repo_prs|
|
|
236
|
+
owner, name = repo_path.split('/', 2)
|
|
237
|
+
pr_numbers = repo_prs.map(&:number)
|
|
238
|
+
result.merge!(fetch_batch_for_repo(owner, name, pr_numbers))
|
|
239
|
+
end
|
|
240
|
+
result
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def refresh_all_status(prs, force: false)
|
|
244
|
+
prs_to_refresh = prs.select { |pr| needs_refresh?(pr, force: force) }
|
|
245
|
+
|
|
246
|
+
if prs_to_refresh.empty?
|
|
247
|
+
puts "All PRs recently checked (within last 2 minutes). Use -U to force update." unless force
|
|
248
|
+
return prs
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# infos is keyed by [number, repo] to avoid collisions across repos
|
|
252
|
+
infos = batch_fetch_pr_info(prs_to_refresh)
|
|
253
|
+
frozen = code_freeze_active?
|
|
254
|
+
|
|
255
|
+
prs_to_refresh.each do |pr|
|
|
256
|
+
info = infos[[pr.number, pr_repo(pr) || 'instacart/carrot']]
|
|
257
|
+
next unless info
|
|
258
|
+
|
|
259
|
+
rollup = info['statusCheckRollup']
|
|
260
|
+
apply_isc_code_freeze_override!(rollup, frozen)
|
|
261
|
+
|
|
262
|
+
pr.state = info['state']
|
|
263
|
+
pr.title = info['title']
|
|
264
|
+
pr.check_runs = rollup
|
|
265
|
+
pr.checks = Hiiro::Git::Pr.summarize_checks(rollup)
|
|
266
|
+
pr.reviews = Hiiro::Git::Pr.summarize_reviews(info['reviews'])
|
|
267
|
+
pr.review_decision = info['reviewDecision']
|
|
268
|
+
pr.is_draft = info['isDraft']
|
|
269
|
+
pr.mergeable = info['mergeable']
|
|
270
|
+
pr.last_checked = Time.now.iso8601
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
prs
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def refresh_status(pr, force: false)
|
|
277
|
+
return pr unless needs_refresh?(pr, force: force)
|
|
278
|
+
|
|
279
|
+
info = fetch_pr_info(pr.number, repo: pr_repo(pr))
|
|
280
|
+
return pr unless info
|
|
281
|
+
|
|
282
|
+
rollup = info.check_runs
|
|
283
|
+
apply_isc_code_freeze_override!(rollup, code_freeze_active?)
|
|
284
|
+
|
|
285
|
+
pr.state = info.state
|
|
286
|
+
pr.title = info.title
|
|
287
|
+
pr.check_runs = rollup
|
|
288
|
+
pr.checks = Hiiro::Git::Pr.summarize_checks(rollup)
|
|
289
|
+
pr.reviews = info.reviews
|
|
290
|
+
pr.review_decision = info.review_decision
|
|
291
|
+
pr.is_draft = info.is_draft
|
|
292
|
+
pr.mergeable = info.mergeable
|
|
293
|
+
pr.last_checked = Time.now.iso8601
|
|
294
|
+
pr
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def display_pinned(pr, idx = nil, widths: {}, oneline: false)
|
|
298
|
+
slot_w = widths[:slot] || 1
|
|
299
|
+
succ_w = widths[:succ] || 1
|
|
300
|
+
total_w = widths[:total] || 1
|
|
301
|
+
as_w = widths[:as] || 1
|
|
302
|
+
crs_w = widths[:crs] || 1
|
|
303
|
+
|
|
304
|
+
slot_num = (pr.slot || (idx ? idx + 1 : 1)).to_s
|
|
305
|
+
num = "#{slot_num.rjust(slot_w)}."
|
|
306
|
+
indent = " " * (slot_w + 2)
|
|
307
|
+
|
|
308
|
+
check_emoji, checks_count_str =
|
|
309
|
+
if pr.checks
|
|
310
|
+
c = pr.checks
|
|
311
|
+
has_failed = c['failed'].to_i > 0
|
|
312
|
+
has_pending = c['pending'].to_i > 0
|
|
313
|
+
only_frozen = has_failed && c['failed'].to_i == c['frozen'].to_i
|
|
314
|
+
emoji = if has_failed && has_pending
|
|
315
|
+
only_frozen ? "⏳❄️" : "⏳❌"
|
|
316
|
+
elsif has_failed
|
|
317
|
+
only_frozen ? " ❄️" : " ❌"
|
|
318
|
+
elsif has_pending
|
|
319
|
+
"⏳ "
|
|
320
|
+
else
|
|
321
|
+
" ✅"
|
|
322
|
+
end
|
|
323
|
+
succ = c['success'].to_i.to_s.rjust(succ_w)
|
|
324
|
+
total = c['total'].to_i.to_s.rjust(total_w)
|
|
325
|
+
[emoji, "#{succ}/#{total}"]
|
|
326
|
+
else
|
|
327
|
+
["", nil]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
state_label = case pr.state
|
|
331
|
+
when 'MERGED' then 'M'
|
|
332
|
+
when 'CLOSED' then 'X'
|
|
333
|
+
else pr.draft? ? 'd' : 'o'
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
bracket_parts = [state_label, check_emoji, checks_count_str].reject { |p| p.nil? || p.empty? }
|
|
337
|
+
state_icon = "[#{bracket_parts.join(' ')}]"
|
|
338
|
+
|
|
339
|
+
r = pr.reviews || {}
|
|
340
|
+
as = r['approved'].to_i
|
|
341
|
+
crs = r['changes_requested'].to_i
|
|
342
|
+
|
|
343
|
+
as_val = as > 0 ? as.to_s : '-'
|
|
344
|
+
crs_val = crs > 0 ? crs.to_s : '-'
|
|
345
|
+
as_colored = as > 0 ? "\e[30;102m#{as_val}\e[0m" : as_val
|
|
346
|
+
crs_colored = crs > 0 ? "\e[30;103m#{crs_val}\e[0m" : crs_val
|
|
347
|
+
as_pad = " " * [as_w - as_val.length, 0].max
|
|
348
|
+
crs_pad = " " * [crs_w - crs_val.length, 0].max
|
|
349
|
+
conflict_str = pr.conflicting? ? " \e[30;101mC\e[0m" : ""
|
|
350
|
+
reviews_str = "#{as_pad}#{as_colored}a/#{crs_pad}#{crs_colored}cr#{conflict_str}"
|
|
351
|
+
|
|
352
|
+
repo = pr_repo(pr)
|
|
353
|
+
repo_label = (repo && repo != 'instacart/carrot') ? "[#{repo}] " : ""
|
|
354
|
+
|
|
355
|
+
tags = Array(pr.tags)
|
|
356
|
+
tags_str = tags.any? ? tags.map { |t| "\e[30;104m#{t}\e[0m" }.join(' ') : nil
|
|
357
|
+
|
|
358
|
+
branch_str = pr.head_branch ? " \e[90m#{pr.head_branch}\e[0m" : ""
|
|
359
|
+
title_str = "\e[1m#{pr.title}\e[0m"
|
|
360
|
+
line1 = "#{num} ##{pr.number} #{state_icon} #{reviews_str}#{branch_str}"
|
|
361
|
+
line2 = "#{indent}#{repo_label}#{title_str}"
|
|
362
|
+
line3 = pr.url ? "#{indent}#{pr.url}" : nil
|
|
363
|
+
line4 = tags_str ? "#{indent}#{tags_str}" : nil
|
|
364
|
+
|
|
365
|
+
if oneline
|
|
366
|
+
"#{line1} #{repo_label}#{title_str}#{tags_str ? " #{tags_str}" : ""}"
|
|
367
|
+
else
|
|
368
|
+
[line1, line2, line3, line4].compact.join("\n")
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def filter_active?(opts)
|
|
373
|
+
FILTER_PREDICATES.keys.any? { |f| opts.respond_to?(f) && opts.send(f) } ||
|
|
374
|
+
(opts.respond_to?(:tag) && Array(opts.tag).any?)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def apply_filters(prs, opts, forced: [])
|
|
378
|
+
active = FILTER_PREDICATES.keys.select { |f| opts.respond_to?(f) && opts.send(f) }
|
|
379
|
+
active = (active + forced).uniq
|
|
380
|
+
|
|
381
|
+
results = active.empty? ? prs : prs.select { |pr| active.any? { |f| FILTER_PREDICATES[f]&.call(pr) } }
|
|
382
|
+
|
|
383
|
+
# Tags are an AND post-filter; multiple tags are OR'd among themselves
|
|
384
|
+
tag_filter = Array(opts.respond_to?(:tag) ? opts.tag : nil).map(&:to_s).reject(&:empty?)
|
|
385
|
+
unless tag_filter.empty?
|
|
386
|
+
results = results.select { |pr| (Array(pr.tags) & tag_filter).any? }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
results
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def display_detailed(pr, idx = nil)
|
|
393
|
+
lines = []
|
|
394
|
+
num = idx ? "#{idx + 1}." : ""
|
|
395
|
+
|
|
396
|
+
state_str = case pr.state
|
|
397
|
+
when 'MERGED' then 'MERGED'
|
|
398
|
+
when 'CLOSED' then 'CLOSED'
|
|
399
|
+
else pr.draft? ? 'DRAFT' : 'OPEN'
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
repo = pr_repo(pr)
|
|
403
|
+
repo_label = (repo && repo != 'instacart/carrot') ? " [#{repo}]" : ""
|
|
404
|
+
|
|
405
|
+
lines << "#{num} ##{pr.number}#{repo_label} - #{pr.title}"
|
|
406
|
+
lines << " State: #{state_str}"
|
|
407
|
+
lines << " Branch: #{pr.head_branch}" if pr.head_branch
|
|
408
|
+
lines << " URL: #{pr.url}" if pr.url
|
|
409
|
+
|
|
410
|
+
if pr.checks
|
|
411
|
+
c = pr.checks
|
|
412
|
+
check_status = if c['failed'] > 0
|
|
413
|
+
"FAILING (#{c['success']}/#{c['total']} passed, #{c['failed']} failed)"
|
|
414
|
+
elsif c['pending'] > 0
|
|
415
|
+
"PENDING (#{c['success']}/#{c['total']} passed, #{c['pending']} pending)"
|
|
416
|
+
else
|
|
417
|
+
"PASSING (#{c['success']}/#{c['total']})"
|
|
418
|
+
end
|
|
419
|
+
lines << " Checks: #{check_status}"
|
|
420
|
+
else
|
|
421
|
+
lines << " Checks: (none)"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
if pr.reviews
|
|
425
|
+
r = pr.reviews
|
|
426
|
+
review_parts = []
|
|
427
|
+
review_parts << "#{r['approved']} approved" if r['approved'] > 0
|
|
428
|
+
review_parts << "#{r['changes_requested']} requesting changes" if r['changes_requested'] > 0
|
|
429
|
+
review_parts << "#{r['commented']} commented" if r['commented'] > 0
|
|
430
|
+
|
|
431
|
+
if review_parts.any?
|
|
432
|
+
lines << " Reviews: #{review_parts.join(', ')}"
|
|
433
|
+
if r['reviewers'] && !r['reviewers'].empty?
|
|
434
|
+
r['reviewers'].each do |author, state|
|
|
435
|
+
icon = case state
|
|
436
|
+
when 'APPROVED' then '+'
|
|
437
|
+
when 'CHANGES_REQUESTED' then '-'
|
|
438
|
+
else '?'
|
|
439
|
+
end
|
|
440
|
+
lines << " #{icon} #{author}: #{state.downcase.gsub('_', ' ')}"
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
else
|
|
444
|
+
lines << " Reviews: (none)"
|
|
445
|
+
end
|
|
446
|
+
else
|
|
447
|
+
lines << " Reviews: (not fetched)"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
lines << " Mergeable: #{pr.mergeable}" if pr.mergeable
|
|
451
|
+
|
|
452
|
+
lines.join("\n")
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def pr_yaml_lines(prs = nil)
|
|
456
|
+
(prs || load_pinned).map do |pr|
|
|
457
|
+
branch = pr.head_branch ? "[#{pr.head_branch}]" : "[##{pr.number}]"
|
|
458
|
+
"- #{pr.number} # #{branch} #{pr.title}"
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def strip_ansi(str)
|
|
463
|
+
str.gsub(/\e\[[0-9;]*m/, '')
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def display_check_runs(pr, indent: " ")
|
|
467
|
+
runs = pr.check_runs
|
|
468
|
+
return unless runs.is_a?(Array) && runs.any?
|
|
469
|
+
|
|
470
|
+
runs.each do |run|
|
|
471
|
+
case run['__typename']
|
|
472
|
+
when 'CheckRun'
|
|
473
|
+
emoji = check_run_emoji(run['conclusion'], run['status'])
|
|
474
|
+
name = run['name'] || run['workflowName'] || '(unknown)'
|
|
475
|
+
url = run['detailsUrl']
|
|
476
|
+
when 'StatusContext'
|
|
477
|
+
emoji = status_context_emoji(run['state'])
|
|
478
|
+
name = run['context'] || '(unknown)'
|
|
479
|
+
url = run['targetUrl']
|
|
480
|
+
else
|
|
481
|
+
emoji = '?'
|
|
482
|
+
name = run['name'] || run['context'] || '(unknown)'
|
|
483
|
+
url = run['detailsUrl'] || run['targetUrl']
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
line = "#{indent}#{emoji} #{name}"
|
|
487
|
+
line += "\n#{indent} #{url}" if url
|
|
488
|
+
puts line
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
private
|
|
493
|
+
|
|
494
|
+
def fetch_batch_for_repo(owner, name, pr_numbers)
|
|
495
|
+
return {} if pr_numbers.empty?
|
|
496
|
+
|
|
497
|
+
pr_queries = pr_numbers.map.with_index do |num, idx|
|
|
498
|
+
<<~GRAPHQL.strip
|
|
499
|
+
pr#{idx}: pullRequest(number: #{num}) {
|
|
500
|
+
number
|
|
501
|
+
title
|
|
502
|
+
url
|
|
503
|
+
headRefName
|
|
504
|
+
state
|
|
505
|
+
isDraft
|
|
506
|
+
mergeable
|
|
507
|
+
reviewDecision
|
|
508
|
+
statusCheckRollup {
|
|
509
|
+
contexts(last: 100) {
|
|
510
|
+
nodes {
|
|
511
|
+
... on CheckRun {
|
|
512
|
+
__typename
|
|
513
|
+
name
|
|
514
|
+
conclusion
|
|
515
|
+
status
|
|
516
|
+
detailsUrl
|
|
517
|
+
}
|
|
518
|
+
... on StatusContext {
|
|
519
|
+
__typename
|
|
520
|
+
context
|
|
521
|
+
state
|
|
522
|
+
targetUrl
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
reviews(last: 50) {
|
|
528
|
+
nodes {
|
|
529
|
+
author {
|
|
530
|
+
login
|
|
531
|
+
}
|
|
532
|
+
state
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
GRAPHQL
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
query = <<~GRAPHQL
|
|
540
|
+
query {
|
|
541
|
+
repository(owner: "#{owner}", name: "#{name}") {
|
|
542
|
+
#{pr_queries.join("\n")}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
GRAPHQL
|
|
546
|
+
|
|
547
|
+
result = `gh api graphql -f query='#{query.gsub("'", "'\\''")}' 2>/dev/null`
|
|
548
|
+
return {} if result.empty?
|
|
549
|
+
|
|
550
|
+
data = JSON.parse(result)
|
|
551
|
+
repo_data = data.dig('data', 'repository')
|
|
552
|
+
return {} unless repo_data
|
|
553
|
+
|
|
554
|
+
pr_info_by_key = {}
|
|
555
|
+
repo_path = "#{owner}/#{name}"
|
|
556
|
+
pr_numbers.each_with_index do |num, idx|
|
|
557
|
+
pr_data = repo_data["pr#{idx}"]
|
|
558
|
+
next unless pr_data
|
|
559
|
+
|
|
560
|
+
pr_info_by_key[[num, repo_path]] = {
|
|
561
|
+
'number' => pr_data['number'],
|
|
562
|
+
'title' => pr_data['title'],
|
|
563
|
+
'url' => pr_data['url'],
|
|
564
|
+
'headRefName' => pr_data['headRefName'],
|
|
565
|
+
'state' => pr_data['state'],
|
|
566
|
+
'isDraft' => pr_data['isDraft'],
|
|
567
|
+
'mergeable' => pr_data['mergeable'],
|
|
568
|
+
'reviewDecision' => pr_data['reviewDecision'],
|
|
569
|
+
'statusCheckRollup' => pr_data.dig('statusCheckRollup', 'contexts', 'nodes'),
|
|
570
|
+
'reviews' => pr_data.dig('reviews', 'nodes') || [],
|
|
571
|
+
'repo' => repo_path
|
|
572
|
+
}
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
pr_info_by_key
|
|
576
|
+
rescue JSON::ParserError, StandardError
|
|
577
|
+
{}
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def check_run_emoji(conclusion, status)
|
|
581
|
+
return "⏳" if %w[QUEUED IN_PROGRESS PENDING REQUESTED WAITING].include?(status) && conclusion.nil?
|
|
582
|
+
case conclusion
|
|
583
|
+
when 'SUCCESS' then "✅"
|
|
584
|
+
when 'FAILURE', 'ERROR' then "❌"
|
|
585
|
+
when 'TIMED_OUT' then "⏰"
|
|
586
|
+
when 'CANCELLED' then "🚫"
|
|
587
|
+
when 'SKIPPED' then "⏭ "
|
|
588
|
+
when 'NEUTRAL' then "⚪"
|
|
589
|
+
when 'STARTUP_FAILURE' then "💥"
|
|
590
|
+
when 'ACTION_REQUIRED' then "⚠️ "
|
|
591
|
+
else "⏳"
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def status_context_emoji(state)
|
|
596
|
+
case state
|
|
597
|
+
when 'SUCCESS' then "✅"
|
|
598
|
+
when 'FAILURE', 'ERROR' then "❌"
|
|
599
|
+
when 'PENDING' then "⏳"
|
|
600
|
+
else "❓"
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|
data/lib/hiiro/version.rb
CHANGED
data/lib/hiiro.rb
CHANGED
data/script/update
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hiiro
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.279
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joshua Toyota
|
|
@@ -270,6 +270,7 @@ files:
|
|
|
270
270
|
- lib/hiiro/notification.rb
|
|
271
271
|
- lib/hiiro/options.rb
|
|
272
272
|
- lib/hiiro/paths.rb
|
|
273
|
+
- lib/hiiro/pinned_pr_manager.rb
|
|
273
274
|
- lib/hiiro/queue.rb
|
|
274
275
|
- lib/hiiro/rbenv.rb
|
|
275
276
|
- lib/hiiro/runner_tool.rb
|