hiiro 0.1.277 → 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: 3c0ebae864202a90016b7b7df7f65a32836cc11c144ea85638a9c75b22279b72
4
- data.tar.gz: 9a5e414ec3dc428f932acdaf47f989c80d2ca50a1fd19fe4d2d7fa9e4c634a76
3
+ metadata.gz: dac54cf95b87a2dd963a261922d9d269597970236714ace84586538ee38b643c
4
+ data.tar.gz: '0408a01c35a5888a2ff78d880085278b719007aa47ed40257749c65410225592'
5
5
  SHA512:
6
- metadata.gz: 4cbdc7c7029c4acfbf6720c47d63d330bb71c270ba1546d76e743a6c05fef40f98229458a39d981627fb49767fac3c63e745b681e12ddde30e180b251ac94087
7
- data.tar.gz: 3a95e13a5ebc85325d8426d398812499924bc8605f7e5e0e785cbb16b3ffafa6161ec7d04d1ff72387286779f8142bcff3f338348c35791192b1d97c72e5f780
6
+ metadata.gz: 74324fbd4fcfdb860270388df1a19f1affff4030d1d0a2c88362c9c3d26f1a1af7170a9eae2303b8467b2a60f6492166cdb2a0f69ba79adbe58dfcc8378ea005
7
+ data.tar.gz: b6d351fd166a8b694c067d83ffba0f63681f37c08565ebd3d24eaa3c2bad7d2a598327b54a47eb49f3ccc04d658b0be12cfd52fae4574a2fcfc712f24742cc8b
data/CHANGELOG.md CHANGED
@@ -1,6 +1 @@
1
- Done. Updated CHANGELOG.md:
2
-
3
- - Moved `Hiiro#add_resolver` and `Hiiro#resolve` from the Unreleased section to the new v0.1.277 release section (2026-03-24)
4
- - Added them to the Added subsection of v0.1.277
5
- - Added a Changed entry documenting the `h pr` refactoring that uses these new methods
6
- - Updated the Unreleased section to remove these items and add a note in Changed about the `h pr` refactoring
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
  }