hiiro 0.1.236 → 0.1.237

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: 57a61c731baafeb477c3f8a1acca82b825ec432019744221b87721bce49d0da3
4
- data.tar.gz: b51a6debe29e2b95b4fd0f489bf905e041637c06ec971ec73d8cf09ae204bc3a
3
+ metadata.gz: 7b4988e7f5ab4be2b903187c815ec22bea49c3817c69b9520390758d5eb16ee0
4
+ data.tar.gz: d16582776302c496a387791be6dfb3424fb6c669af6efbcf38f25c555feb3e88
5
5
  SHA512:
6
- metadata.gz: a3e55905cbfc8c4f80d28fd3b4cfdfc04c5cf7566e20efb091a9ed945d0aa45907d45b085d3828506f20d43a07eb79e7991af6f9adb47b0ed197a6474ee66934
7
- data.tar.gz: 3ecc52af4a2d7d822a10df2e78a994929280640cbe76ac7badd1039911cf54784437bbbb2961065972b0ad06e61c950dc092e3cd031c482f831919258e7a4965
6
+ metadata.gz: bd23321769a10e3181d1e88ef9f6a953ff86fab5d4037e03058a55760ae22f64082e20a779f372a06d1f588909733a82ead732ce0d305f05873846913bc01937
7
+ data.tar.gz: a67d8dbd70576be90258660e17708ecb86b0d823cbc10be838f05ddeb1fb1de307ec72b8d9d1247f96cbbbca2a0a5734beb89b0e39e53547663fbdd48ebdc570
data/bin/h-branch CHANGED
@@ -116,14 +116,130 @@ class BranchManager
116
116
  end
117
117
  end
118
118
 
119
+ BRANCH_TAG_OPTS = Proc.new {
120
+ option(:tag, short: 't', desc: 'filter by tag (OR when multiple)', multi: true)
121
+ }
122
+
119
123
  Hiiro.run(*ARGV) do
120
- manager = BranchManager.new(self)
124
+ manager = BranchManager.new(self)
125
+ tag_store = Hiiro::Tags.new(:branch)
121
126
 
122
127
  add_subcmd(:edit) { edit_files(__FILE__) }
123
128
  add_subcmd(:save) { manager.save }
124
129
  add_subcmd(:current) { print `git branch --show-current` }
125
130
  add_subcmd(:info) { manager.current }
126
131
 
132
+ add_subcmd(:ls) do |*ls_args|
133
+ opts = Hiiro::Options.parse(ls_args, &BRANCH_TAG_OPTS)
134
+ branches = git.branches(sort_by: 'authordate', ignore_case: true)
135
+ current = git.branch
136
+ tags_all = tag_store.all # { branch_name => [tags] }
137
+
138
+ tag_filter = Array(opts.tag).reject(&:empty?)
139
+ if tag_filter.any?
140
+ branches = branches.select { |b| (Array(tags_all[b]) & tag_filter).any? }
141
+ end
142
+
143
+ if branches.empty?
144
+ puts tag_filter.any? ? "No branches match tags: #{tag_filter.join(', ')}" : "No branches found."
145
+ next
146
+ end
147
+
148
+ branches.each do |b|
149
+ marker = b == current ? "* " : " "
150
+ tags = Array(tags_all[b])
151
+ tag_str = tags.any? ? " " + Hiiro::Tags.badges(tags) : ""
152
+ puts "#{marker}#{b}#{tag_str}"
153
+ end
154
+ end
155
+
156
+ add_subcmd(:tag) do |*raw_args|
157
+ opts = Hiiro::Options.parse(raw_args) { flag(:edit, short: 'e', desc: 'open YAML editor to bulk-tag branches') }
158
+ branches = git.branches(sort_by: 'authordate', ignore_case: true)
159
+
160
+ if opts.edit
161
+ branch_lines = branches.map { |b| "- #{b}" }
162
+ yaml_content = <<~YAML
163
+ # Select branches to tag and list the tags to apply.
164
+ # All listed branches will receive all listed tags.
165
+
166
+ branches:
167
+ #{branch_lines.join("\n")}
168
+
169
+ tags:
170
+ -
171
+ YAML
172
+
173
+ require 'tempfile'
174
+ tmpfile = Tempfile.new(['branch-tag-', '.yml'])
175
+ tmpfile.write(yaml_content)
176
+ tmpfile.close
177
+ edit_files(tmpfile.path)
178
+
179
+ parsed = YAML.safe_load(File.read(tmpfile.path)) rescue nil
180
+ tmpfile.unlink
181
+
182
+ unless parsed.is_a?(Hash)
183
+ puts "Aborted: could not parse YAML"
184
+ next
185
+ end
186
+
187
+ selected = Array(parsed['branches']).map(&:to_s).reject(&:empty?)
188
+ new_tags = Array(parsed['tags']).map(&:to_s).reject(&:empty?)
189
+
190
+ if selected.empty? || new_tags.empty?
191
+ puts "Aborted: need at least one branch and one tag"
192
+ next
193
+ end
194
+
195
+ selected.each do |b|
196
+ tag_store.add(b, *new_tags)
197
+ puts "Tagged #{b} with: #{new_tags.join(', ')}"
198
+ end
199
+ else
200
+ branch = opts.args[0] || git.branch
201
+ tag_names = opts.args[1..]
202
+
203
+ if tag_names.empty?
204
+ puts "Usage: h branch tag [branch] <tag> [tag2 ...]"
205
+ puts " h branch tag -e (bulk edit mode)"
206
+ next
207
+ end
208
+
209
+ result = tag_store.add(branch, *tag_names)
210
+ puts "Tagged #{branch} with: #{tag_names.join(', ')}"
211
+ puts " Tags now: #{result.join(', ')}"
212
+ end
213
+ end
214
+
215
+ add_subcmd(:untag) do |*raw_args|
216
+ branch = raw_args[0] || git.branch
217
+ tag_names = raw_args[1..]
218
+
219
+ tag_store.remove(branch, *tag_names)
220
+ if tag_names.empty?
221
+ puts "Cleared all tags from #{branch}"
222
+ else
223
+ puts "Removed #{tag_names.join(', ')} from #{branch}"
224
+ end
225
+ end
226
+
227
+ add_subcmd(:tags) do
228
+ all = tag_store.all
229
+ if all.empty?
230
+ puts "No tagged branches."
231
+ next
232
+ end
233
+
234
+ by_tag = Hash.new { |h, k| h[k] = [] }
235
+ all.each { |branch, tags| tags.each { |t| by_tag[t] << branch } }
236
+
237
+ by_tag.sort.each do |tag, brs|
238
+ puts "#{Hiiro::Tags.badges([tag])} (#{brs.length})"
239
+ brs.each { |b| puts " #{b}" }
240
+ end
241
+ end
242
+
127
243
  add_subcmd(:select) do |*select_args|
128
244
  branches = git.branches(sort_by: 'authordate', ignore_case: true)
129
245
 
data/bin/h-pr CHANGED
@@ -392,7 +392,7 @@ class PinnedPRManager
392
392
 
393
393
  def filter_active?(opts)
394
394
  FILTER_PREDICATES.keys.any? { |f| opts.respond_to?(f) && opts.send(f) } ||
395
- (opts.respond_to?(:tag) && opts.tag)
395
+ (opts.respond_to?(:tag) && Array(opts.tag).any?)
396
396
  end
397
397
 
398
398
  def apply_filters(prs, opts, forced: [])
@@ -401,10 +401,10 @@ class PinnedPRManager
401
401
 
402
402
  results = active.empty? ? prs : prs.select { |pr| active.any? { |f| FILTER_PREDICATES[f]&.call(pr) } }
403
403
 
404
- # Tag is an AND post-filter narrows whatever the flag filters returned
405
- if opts.respond_to?(:tag) && opts.tag
406
- tag = opts.tag.to_s
407
- results = results.select { |pr| Array(pr['tags']).include?(tag) }
404
+ # Tags are an AND post-filter; multiple tags are OR'd among themselves
405
+ tag_filter = Array(opts.respond_to?(:tag) ? opts.tag : nil).map(&:to_s).reject(&:empty?)
406
+ unless tag_filter.empty?
407
+ results = results.select { |pr| (Array(pr['tags']) & tag_filter).any? }
408
408
  end
409
409
 
410
410
  results
@@ -488,7 +488,7 @@ FILTER_OPTS = Proc.new {
488
488
  flag(:merged, short: 'm', desc: 'filter: merged PRs')
489
489
  flag(:active, short: 'o', desc: 'filter: open (non-merged) PRs')
490
490
  flag(:numbers, short: 'n', desc: 'output PR numbers only (no #)')
491
- option(:tag, short: 't', desc: 'filter by tag (AND with other filters)')
491
+ option(:tag, short: 't', desc: 'filter by tag (OR when multiple; AND with flag filters)', multi: true)
492
492
  }
493
493
 
494
494
  Hiiro.run(*ARGV, plugins: [Pins]) do
@@ -1332,26 +1332,94 @@ Hiiro.run(*ARGV, plugins: [Pins]) do
1332
1332
 
1333
1333
  # === Tags ===
1334
1334
 
1335
- add_subcmd(:tag) do |ref = nil, *tag_names|
1336
- if ref.nil? || tag_names.empty?
1337
- puts "Usage: h pr tag <ref> <tag> [tag2 ...]"
1338
- next
1335
+ add_subcmd(:tag) do |*raw_args|
1336
+ opts = Hiiro::Options.parse(raw_args) do
1337
+ flag(:edit, short: 'e', desc: 'open YAML editor to bulk-tag multiple PRs')
1339
1338
  end
1340
1339
 
1341
- pr_number = resolve_pr.call(ref)
1342
- next unless pr_number
1340
+ if opts.edit
1341
+ pinned = pinned_manager.load_pinned
1343
1342
 
1344
- pinned = pinned_manager.load_pinned
1345
- pr = pinned.find { |p| p['number'].to_s == pr_number.to_s }
1346
- unless pr
1347
- puts "PR ##{pr_number} not in tracked list"
1348
- next
1349
- end
1343
+ if pinned.empty?
1344
+ puts "No tracked PRs to tag"
1345
+ next
1346
+ end
1350
1347
 
1351
- pr['tags'] = (Array(pr['tags']) + tag_names).uniq
1352
- pinned_manager.save_pinned(pinned)
1353
- puts "Tagged ##{pr_number} with: #{tag_names.join(', ')}"
1354
- puts " Tags now: #{pr['tags'].join(', ')}"
1348
+ pr_lines = pinned.map do |pr|
1349
+ branch = pr['headRefName'] ? "[#{pr['headRefName']}]" : "[##{pr['number']}]"
1350
+ "- #{pr['number']} # #{branch} #{pr['title']}"
1351
+ end
1352
+
1353
+ yaml_content = <<~YAML
1354
+ # Select PRs to tag and list the tags to apply.
1355
+ # All listed PRs will receive all listed tags.
1356
+ # Lines starting with # are comments and are ignored.
1357
+
1358
+ prs:
1359
+ #{pr_lines.join("\n")}
1360
+
1361
+ tags:
1362
+ -
1363
+ YAML
1364
+
1365
+ tmpfile = Tempfile.new(['pr-tag-', '.yml'])
1366
+ tmpfile.write(yaml_content)
1367
+ tmpfile.close
1368
+ edit_files(tmpfile.path)
1369
+
1370
+ parsed = YAML.safe_load(File.read(tmpfile.path)) rescue nil
1371
+ tmpfile.unlink
1372
+
1373
+ unless parsed.is_a?(Hash)
1374
+ puts "Aborted: could not parse YAML"
1375
+ next
1376
+ end
1377
+
1378
+ selected_numbers = Array(parsed['prs']).map(&:to_s).reject(&:empty?)
1379
+ new_tags = Array(parsed['tags']).map(&:to_s).reject(&:empty?)
1380
+
1381
+ if selected_numbers.empty? || new_tags.empty?
1382
+ puts "Aborted: need at least one PR and one tag"
1383
+ next
1384
+ end
1385
+
1386
+ pinned = pinned_manager.load_pinned
1387
+ updated = 0
1388
+ selected_numbers.each do |num|
1389
+ pr = pinned.find { |p| p['number'].to_s == num }
1390
+ next unless pr
1391
+ pr['tags'] = (Array(pr['tags']) + new_tags).uniq
1392
+ updated += 1
1393
+ puts "Tagged ##{num} with: #{new_tags.join(', ')}"
1394
+ end
1395
+
1396
+ pinned_manager.save_pinned(pinned)
1397
+ puts "Updated #{updated} PR(s)."
1398
+ else
1399
+ ref = opts.args[0]
1400
+ tag_names = opts.args[1..]
1401
+
1402
+ if ref.nil? || tag_names.empty?
1403
+ puts "Usage: h pr tag <ref> <tag> [tag2 ...]"
1404
+ puts " h pr tag -e (bulk edit mode)"
1405
+ next
1406
+ end
1407
+
1408
+ pr_number = resolve_pr.call(ref)
1409
+ next unless pr_number
1410
+
1411
+ pinned = pinned_manager.load_pinned
1412
+ pr = pinned.find { |p| p['number'].to_s == pr_number.to_s }
1413
+ unless pr
1414
+ puts "PR ##{pr_number} not in tracked list"
1415
+ next
1416
+ end
1417
+
1418
+ pr['tags'] = (Array(pr['tags']) + tag_names).uniq
1419
+ pinned_manager.save_pinned(pinned)
1420
+ puts "Tagged ##{pr_number} with: #{tag_names.join(', ')}"
1421
+ puts " Tags now: #{pr['tags'].join(', ')}"
1422
+ end
1355
1423
  end
1356
1424
 
1357
1425
  add_subcmd(:untag) do |ref = nil, *tag_names|
data/lib/hiiro/tags.rb ADDED
@@ -0,0 +1,71 @@
1
+ class Hiiro
2
+ # Shared tag store, keyed by namespace (e.g. :branch, :task).
3
+ # Stored in ~/.config/hiiro/tags.yml as { namespace => { key => [tags] } }.
4
+ class Tags
5
+ FILE = Hiiro::Config.path('tags.yml')
6
+
7
+ def initialize(namespace)
8
+ @namespace = namespace.to_s
9
+ end
10
+
11
+ # Returns the tag array for a given key ([] if none).
12
+ def get(key)
13
+ Array(load.dig(@namespace, key.to_s))
14
+ end
15
+
16
+ # Adds tags to a key (idempotent). Returns the new tag array.
17
+ def add(key, *tags)
18
+ data = load
19
+ data[@namespace] ||= {}
20
+ current = Array(data.dig(@namespace, key.to_s))
21
+ data[@namespace][key.to_s] = (current + tags.map(&:to_s)).uniq
22
+ save(data)
23
+ data[@namespace][key.to_s]
24
+ end
25
+
26
+ # Removes specific tags from a key. Pass no tags to clear all.
27
+ def remove(key, *tags)
28
+ data = load
29
+ data[@namespace] ||= {}
30
+ current = Array(data.dig(@namespace, key.to_s))
31
+ updated = tags.empty? ? [] : (current - tags.map(&:to_s))
32
+ if updated.empty?
33
+ data[@namespace].delete(key.to_s)
34
+ else
35
+ data[@namespace][key.to_s] = updated
36
+ end
37
+ save(data)
38
+ end
39
+
40
+ # Returns the full { key => [tags] } hash for this namespace.
41
+ def all
42
+ load[@namespace] || {}
43
+ end
44
+
45
+ # Returns all distinct tag values used in this namespace.
46
+ def known_tags
47
+ all.values.flatten.uniq.sort
48
+ end
49
+
50
+ # Formats a tag array as colored badges for terminal output.
51
+ def self.badges(tags)
52
+ Array(tags).map { |t| "\e[30;104m#{t}\e[0m" }.join(' ')
53
+ end
54
+
55
+ private
56
+
57
+ def load
58
+ return {} unless File.exist?(FILE)
59
+ YAML.safe_load(File.read(FILE)) || {}
60
+ rescue
61
+ {}
62
+ end
63
+
64
+ def save(data)
65
+ data.each { |_, v| v.reject! { |_, tags| tags.nil? || tags.empty? } if v.is_a?(Hash) }
66
+ data.reject! { |_, v| v.nil? || v.empty? }
67
+ FileUtils.mkdir_p(File.dirname(FILE))
68
+ File.write(FILE, data.to_yaml)
69
+ end
70
+ end
71
+ end
data/lib/hiiro/tasks.rb CHANGED
@@ -182,11 +182,21 @@ class Hiiro
182
182
  puts "Stopped task '#{task.name}' (worktree available for reuse)"
183
183
  end
184
184
 
185
- def list
185
+ def list(tags_filter: [])
186
+ tag_store = Hiiro::Tags.new(:task)
186
187
  items = tasks
188
+
189
+ if tags_filter.any?
190
+ items = items.select { |t| (tag_store.get(t.name) & tags_filter).any? }
191
+ end
192
+
187
193
  if items.empty?
188
- puts scope == :subtask ? "No subtasks found" : "No tasks found"
189
- puts "Use 'h #{scope} start NAME' to create one."
194
+ if tags_filter.any?
195
+ puts "No #{scope == :subtask ? 'subtasks' : 'tasks'} match tags: #{tags_filter.join(', ')}"
196
+ else
197
+ puts scope == :subtask ? "No subtasks found" : "No tasks found"
198
+ puts "Use 'h #{scope} start NAME' to create one."
199
+ end
190
200
  return
191
201
  end
192
202
 
@@ -227,9 +237,11 @@ class Hiiro
227
237
 
228
238
  rows.each do |r|
229
239
  name_pad = name_col - r[:prefix].length
240
+ tags = tag_store.get(r[:name])
241
+ tag_str = tags.any? ? " " + Hiiro::Tags.badges(tags) : ""
230
242
  print r[:prefix]
231
243
  puts format("%-#{name_pad}s %-#{tree_col}s %-#{branch_col}s %s",
232
- r[:name], r[:tree], r[:branch], r[:session])
244
+ r[:name], r[:tree], r[:branch], r[:session]) + tag_str
233
245
  end
234
246
 
235
247
  available = environment.all_trees.reject { |t|
@@ -649,8 +661,76 @@ class Hiiro
649
661
  module Tasks
650
662
  def self.build_hiiro(parent_hiiro, tm)
651
663
  task_hiiro = parent_hiiro.make_child do |h|
652
- h.add_subcmd(:list) { tm.list }
653
- h.add_subcmd(:ls) { tm.list }
664
+ h.add_subcmd(:list) do |*args|
665
+ opts = Hiiro::Options.parse(args) { option(:tag, short: 't', desc: 'filter by tag (OR when multiple)', multi: true) }
666
+ tm.list(tags_filter: Array(opts.tag).reject(&:empty?))
667
+ end
668
+ h.add_subcmd(:ls) do |*args|
669
+ opts = Hiiro::Options.parse(args) { option(:tag, short: 't', desc: 'filter by tag (OR when multiple)', multi: true) }
670
+ tm.list(tags_filter: Array(opts.tag).reject(&:empty?))
671
+ end
672
+
673
+ h.add_subcmd(:tag) do |*raw_args|
674
+ tag_store = Hiiro::Tags.new(:task)
675
+ opts = Hiiro::Options.parse(raw_args) { flag(:edit, short: 'e', desc: 'open YAML editor to bulk-tag tasks') }
676
+
677
+ if opts.edit
678
+ task_names = tm.tasks.map(&:name)
679
+ task_lines = task_names.map { |n| "- #{n}" }
680
+ yaml_content = "# Select tasks to tag.\n\ntasks:\n#{task_lines.join("\n")}\n\ntags:\n-\n"
681
+ require 'tempfile'
682
+ tmpfile = Tempfile.new(['task-tag-', '.yml'])
683
+ tmpfile.write(yaml_content)
684
+ tmpfile.close
685
+ h.edit_files(tmpfile.path)
686
+ parsed = YAML.safe_load(File.read(tmpfile.path)) rescue nil
687
+ tmpfile.unlink
688
+ selected = Array(parsed&.dig('tasks')).map(&:to_s).reject(&:empty?)
689
+ new_tags = Array(parsed&.dig('tags')).map(&:to_s).reject(&:empty?)
690
+ if selected.empty? || new_tags.empty?
691
+ puts "Aborted: need at least one task and one tag"
692
+ next
693
+ end
694
+ selected.each { |n| tag_store.add(n, *new_tags); puts "Tagged #{n} with: #{new_tags.join(', ')}" }
695
+ else
696
+ task_name = opts.args[0] || tm.current_task&.name
697
+ tag_names = opts.args[1..]
698
+ if task_name.nil? || tag_names.empty?
699
+ puts "Usage: h task tag [task_name] <tag> [tag2 ...]"
700
+ next
701
+ end
702
+ result = tag_store.add(task_name, *tag_names)
703
+ puts "Tagged #{task_name} with: #{tag_names.join(', ')}"
704
+ puts " Tags now: #{result.join(', ')}"
705
+ end
706
+ end
707
+
708
+ h.add_subcmd(:untag) do |*raw_args|
709
+ tag_store = Hiiro::Tags.new(:task)
710
+ task_name = raw_args[0] || tm.current_task&.name
711
+ tag_names = raw_args[1..]
712
+ if task_name.nil?
713
+ puts "Usage: h task untag [task_name] [tag ...]"
714
+ next
715
+ end
716
+ tag_store.remove(task_name, *tag_names)
717
+ puts tag_names.empty? ? "Cleared all tags from #{task_name}" : "Removed #{tag_names.join(', ')} from #{task_name}"
718
+ end
719
+
720
+ h.add_subcmd(:tags) do
721
+ tag_store = Hiiro::Tags.new(:task)
722
+ all = tag_store.all
723
+ if all.empty?
724
+ puts "No tagged tasks."
725
+ next
726
+ end
727
+ by_tag = Hash.new { |h2, k| h2[k] = [] }
728
+ all.each { |task, tags| tags.each { |t| by_tag[t] << task } }
729
+ by_tag.sort.each do |tag, tasks|
730
+ puts "#{Hiiro::Tags.badges([tag])} (#{tasks.length})"
731
+ tasks.each { |t| puts " #{t}" }
732
+ end
733
+ end
654
734
 
655
735
  h.add_subcmd(:start) do |*raw_args|
656
736
  opts = Hiiro::Options.parse(raw_args) do
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.236"
2
+ VERSION = "0.1.237"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "hiiro/matcher"
14
14
  require_relative "hiiro/notification"
15
15
  require_relative "hiiro/options"
16
16
  require_relative "hiiro/paths"
17
+ require_relative "hiiro/tags"
17
18
  require_relative "hiiro/queue"
18
19
  require_relative "hiiro/tasks"
19
20
  require_relative "hiiro/tmux"
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.236
4
+ version: 0.1.237
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
@@ -158,6 +158,7 @@ files:
158
158
  - lib/hiiro/runner_tool.rb
159
159
  - lib/hiiro/service_manager.rb
160
160
  - lib/hiiro/shell.rb
161
+ - lib/hiiro/tags.rb
161
162
  - lib/hiiro/tasks.rb
162
163
  - lib/hiiro/tmux.rb
163
164
  - lib/hiiro/tmux/buffer.rb