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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1beafe3505bb704e88e7352d6f92c42023c0b6682574eafd5775f365bd7dc444
4
- data.tar.gz: d3074244aabc4c9c64c880688ae45628137fa58d0babd960f3a8fd5bdb7aeaf6
3
+ metadata.gz: dac54cf95b87a2dd963a261922d9d269597970236714ace84586538ee38b643c
4
+ data.tar.gz: '0408a01c35a5888a2ff78d880085278b719007aa47ed40257749c65410225592'
5
5
  SHA512:
6
- metadata.gz: f7a5bbd8919dc92f5209112abdf6a2997890408bbdf3c71dda6e6adf662350a3b6c2502f4fe2d4dbaf24f02ec50ac4a591ec2c574d1e55856d743946c17d8150
7
- data.tar.gz: 7cfa0e4c81959f8db363dfefa29e27faed97941dd72b8c83ef6b2f0a21bac65ca7caf5c06ec19ecaba6d978912a6c7c0e565ef92db1e5eabc24fa4cbd6bcfeb1
6
+ metadata.gz: 74324fbd4fcfdb860270388df1a19f1affff4030d1d0a2c88362c9c3d26f1a1af7170a9eae2303b8467b2a60f6492166cdb2a0f69ba79adbe58dfcc8378ea005
7
+ data.tar.gz: b6d351fd166a8b694c067d83ffba0f63681f37c08565ebd3d24eaa3c2bad7d2a598327b54a47eb49f3ccc04d658b0be12cfd52fae4574a2fcfc712f24742cc8b
data/CHANGELOG.md CHANGED
@@ -1 +1 @@
1
- Done. The CHANGELOG.md has been updated with v0.1.278 released on 2026-03-24. The pane-based queue launch modes (cadd, hadd, vadd) and related changes have been moved from Unreleased to the new v0.1.278 section, and the `add_resolvers` class methods have been added to the Added section based on commit 1bba9fd.
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
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.278"
2
+ VERSION = "0.1.279"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -27,6 +27,7 @@ require_relative "hiiro/runner_tool"
27
27
  require_relative "hiiro/app_files"
28
28
  require_relative "hiiro/rbenv"
29
29
  require_relative "hiiro/any_struct"
30
+ require_relative "hiiro/pinned_pr_manager"
30
31
 
31
32
  class String
32
33
  def underscore(camel_cased_word=self)
data/script/update CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/bin/bash
2
2
 
3
- gem install hiiro
3
+ if rbenv which h
4
+ then
5
+ gem update hiiro
6
+ h update
7
+
8
+ h update -a
9
+ else
10
+ gem install hiiro
11
+ fi
4
12
 
5
13
  h setup
6
14
 
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.278
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