Class: Hiiro::Git::Pr

Inherits:
Object
  • Object
show all
Defined in:
lib/hiiro/git/pr.rb

Constant Summary collapse

PINNED_FILE =
Hiiro::Config.path('pinned_prs.yml')
FAILED_CONCLUSIONS =
%w[FAILURE ERROR TIMED_OUT STALE STARTUP_FAILURE ACTION_REQUIRED].freeze
STATE_FILTER_KEYS =

Filter dimensions. Flags within each group OR together; groups AND together. e.g. -o -g → (active?) AND (green?), -o -r -g → (active?) AND (red? OR green?)

%i[active merged drafts conflicts].freeze
CHECK_FILTER_KEYS =
%i[red green pending].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(number:, title: nil, state: nil, url: nil, head_branch: nil, base_branch: nil, repo: nil, slot: nil, is_draft: nil, mergeable: nil, review_decision: nil, checks: nil, check_runs: nil, reviews: nil, last_checked: nil, pinned_at: nil, updated_at: nil, task: nil, worktree: nil, tmux_session: nil, tags: nil, assigned: nil, authored: nil, depends_on: nil) ⇒ Pr

Returns a new instance of Pr.



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/hiiro/git/pr.rb', line 192

def initialize(number:, title: nil, state: nil, url: nil, head_branch: nil, base_branch: nil,
               repo: nil, slot: nil, is_draft: nil, mergeable: nil, review_decision: nil,
               checks: nil, check_runs: nil, reviews: nil, last_checked: nil,
               pinned_at: nil, updated_at: nil,
               task: nil, worktree: nil, tmux_session: nil, tags: nil, assigned: nil, authored: nil,
               depends_on: nil)
  @number          = number
  @title           = title
  @state           = state
  @url             = url
  @head_branch     = head_branch
  @base_branch     = base_branch
  @repo            = repo
  @slot            = slot
  @is_draft        = is_draft
  @mergeable       = mergeable
  @review_decision = review_decision
  @checks          = checks
  @check_runs      = check_runs
  @reviews         = reviews
  @last_checked    = last_checked
  @pinned_at       = pinned_at
  @updated_at      = updated_at
  @task            = task
  @worktree        = worktree
  @tmux_session    = tmux_session
  @tags            = tags
  @assigned        = assigned
  @authored        = authored
  @depends_on      = depends_on ? Array(depends_on).map(&:to_i) : nil
end

Instance Attribute Details

#assignedObject

Returns the value of attribute assigned.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def assigned
  @assigned
end

#authoredObject

Returns the value of attribute authored.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def authored
  @authored
end

#base_branchObject

Returns the value of attribute base_branch.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def base_branch
  @base_branch
end

#check_runsObject

Returns the value of attribute check_runs.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def check_runs
  @check_runs
end

#checksObject

Returns the value of attribute checks.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def checks
  @checks
end

#depends_onObject

Returns the value of attribute depends_on.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def depends_on
  @depends_on
end

#head_branchObject

Returns the value of attribute head_branch.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def head_branch
  @head_branch
end

#is_draftObject

Returns the value of attribute is_draft.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def is_draft
  @is_draft
end

#last_checkedObject

Returns the value of attribute last_checked.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def last_checked
  @last_checked
end

#mergeableObject

Returns the value of attribute mergeable.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def mergeable
  @mergeable
end

#numberObject

Returns the value of attribute number.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def number
  @number
end

#pinned_atObject

Returns the value of attribute pinned_at.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def pinned_at
  @pinned_at
end

#repoObject

Returns the value of attribute repo.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def repo
  @repo
end

#review_decisionObject

Returns the value of attribute review_decision.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def review_decision
  @review_decision
end

#reviewsObject

Returns the value of attribute reviews.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def reviews
  @reviews
end

#slotObject

Returns the value of attribute slot.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def slot
  @slot
end

#stateObject

Returns the value of attribute state.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def state
  @state
end

#tagsObject

Returns the value of attribute tags.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def tags
  @tags
end

#taskObject

Returns the value of attribute task.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def task
  @task
end

#titleObject

Returns the value of attribute title.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def title
  @title
end

#tmux_sessionObject

Returns the value of attribute tmux_session.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def tmux_session
  @tmux_session
end

#updated_atObject

Returns the value of attribute updated_at.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def updated_at
  @updated_at
end

#urlObject

Returns the value of attribute url.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def url
  @url
end

#worktreeObject

Returns the value of attribute worktree.



9
10
11
# File 'lib/hiiro/git/pr.rb', line 9

def worktree
  @worktree
end

Class Method Details

.create(title:, body: nil, base: nil, draft: false) ⇒ Object



125
126
127
128
129
130
131
# File 'lib/hiiro/git/pr.rb', line 125

def self.create(title:, body: nil, base: nil, draft: false)
  args = ['gh', 'pr', 'create', '--title', title]
  args += ['--body', body] if body
  args += ['--base', base] if base
  args << '--draft' if draft
  system(*args)
end

.currentObject



182
183
184
185
186
187
188
189
190
# File 'lib/hiiro/git/pr.rb', line 182

def self.current
  output = `gh pr view --json number,title,state,url,headRefName,baseRefName 2>/dev/null`
  return nil if output.empty?

  require 'json'
  from_gh_json(JSON.parse(output))
rescue
  nil
end

.from_gh_json(data) ⇒ Object

Build a Pr from GitHub API JSON (gh pr view –json or GraphQL). Stores both the raw check run nodes (check_runs) and the summary (checks).



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/hiiro/git/pr.rb', line 93

def self.from_gh_json(data)
  rollup   = data['statusCheckRollup']
  rollup   = rollup.is_a?(Array) && rollup.any? ? rollup : nil
  reviews  = data['reviews'].is_a?(Array) ? data['reviews'] : data.dig('reviews', 'nodes')

  new(
    number:          data['number'],
    title:           data['title'],
    state:           data['state'],
    url:             data['url'],
    head_branch:     data['headRefName'],
    base_branch:     data['baseRefName'],
    repo:            data['repo'] || repo_from_url(data['url']),
    is_draft:        data['isDraft'],
    mergeable:       data['mergeable'],
    review_decision: data['reviewDecision'],
    checks:          rollup  ? summarize_checks(rollup)   : nil,
    check_runs:      rollup,
    reviews:         reviews ? summarize_reviews(reviews) : nil,
  )
end


30
31
32
33
34
35
36
37
38
39
40
# File 'lib/hiiro/git/pr.rb', line 30

def self.from_link(link)
  return nil unless is_link?(link)

  number = link[/pull\/(\d+)/].sub(/\D*/, '')
  owner, name, _ = link.sub(/.*github.com./, '').split(?/, 3)
  new(
    number: number,
    url: link,
    repo: [owner, name].join(?/),
  )
end

.from_number(number) ⇒ Object



42
43
44
45
46
47
# File 'lib/hiiro/git/pr.rb', line 42

def self.from_number(number)
  number = number.to_s.strip[/^\d+$/]
  return if number&.length == 0

  new(number: number)
end

.from_pinned_hash(hash) ⇒ Object

Build a Pr from a stored YAML hash. Handles both camelCase keys (legacy) and snake_case keys. Falls back to computing checks from raw statusCheckRollup if the summarized checks hash is missing (pre-refresh data).



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/hiiro/git/pr.rb', line 57

def self.from_pinned_hash(hash)
  raw_rollup = hash['statusCheckRollup']
  raw_rollup = nil unless raw_rollup.is_a?(Array) && raw_rollup.any?
  stored_checks = hash['checks']
  checks = stored_checks || (raw_rollup ? summarize_checks(raw_rollup) : nil)

  new(
    number:          hash['number'],
    title:           hash['title'],
    state:           hash['state'],
    url:             hash['url'],
    head_branch:     hash['headRefName'] || hash['head_branch'],
    base_branch:     hash['baseRefName'] || hash['base_branch'],
    repo:            hash['repo'],
    slot:            hash['slot'],
    is_draft:        hash.key?('is_draft') ? hash['is_draft'] : hash['isDraft'],
    mergeable:       hash['mergeable'],
    review_decision: hash['review_decision'] || hash['reviewDecision'],
    checks:          checks,
    check_runs:      raw_rollup,
    reviews:         hash['reviews'],
    last_checked:    hash['last_checked'],
    pinned_at:       hash['pinned_at'],
    updated_at:      hash['updated_at'],
    task:            hash['task'],
    worktree:        hash['worktree'],
    tmux_session:    hash['tmux_session'],
    tags:            hash['tags'],
    assigned:        hash['assigned'],
    authored:        hash['authored'],
    depends_on:      hash['depends_on'],
  )
end

.is_link?(link) ⇒ Boolean

Returns:

  • (Boolean)


23
24
25
26
27
28
# File 'lib/hiiro/git/pr.rb', line 23

def self.is_link?(link)
  temp_link = link.to_s
  return false unless temp_link.match?('github.com') && temp_link.match?(/pull\/[0-9]+/)

  true
end

.list(state: 'open', limit: 30) ⇒ Object



115
116
117
118
119
120
121
122
123
# File 'lib/hiiro/git/pr.rb', line 115

def self.list(state: 'open', limit: 30)
  output = `gh pr list --state #{state} --limit #{limit} --json number,title,state,url,headRefName,baseRefName 2>/dev/null`
  return [] if output.empty?

  require 'json'
  JSON.parse(output).map { |data| from_gh_json(data) }
rescue
  []
end

.pinned_prsObject

Load all pinned PRs from YAML, returning an array of Pr instances.



15
16
17
18
19
20
21
# File 'lib/hiiro/git/pr.rb', line 15

def self.pinned_prs
  return [] unless File.exist?(PINNED_FILE)
  prs = YAML.load_file(PINNED_FILE) || []
  prs.map { |h| from_pinned_hash(h) }
rescue
  []
end

.repo_from_url(url) ⇒ Object



49
50
51
52
# File 'lib/hiiro/git/pr.rb', line 49

def self.repo_from_url(url)
  return nil unless url
  url.match(%r{github\.com/([^/]+/[^/]+)/pull/})&.[](1)
end

.summarize_checks(rollup, truncated: false) ⇒ Object

Summarizes raw statusCheckRollup contexts into { total, success, pending, failed, frozen }. frozen = number of failed contexts that are specifically the ISC code freeze check. truncated: true is added when pagination couldn’t retrieve all checks.



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/hiiro/git/pr.rb', line 136

def self.summarize_checks(rollup, truncated: false)
  return nil unless rollup

  contexts = rollup.is_a?(Array) ? rollup : []
  return nil if contexts.empty?

  total   = contexts.length
  success = contexts.count { |c| c['conclusion'] == 'SUCCESS' || c['state'] == 'SUCCESS' }
  pending = contexts.count do |c|
    %w[QUEUED IN_PROGRESS PENDING REQUESTED WAITING].include?(c['status']) ||
      c['state'] == 'PENDING'
  end
  failed  = contexts.count do |c|
    FAILED_CONCLUSIONS.include?(c['conclusion']) || %w[FAILURE ERROR].include?(c['state'])
  end
  frozen  = contexts.count do |c|
    c['context'] == 'ISC code freeze' &&
      (FAILED_CONCLUSIONS.include?(c['conclusion']) || %w[FAILURE ERROR].include?(c['state']))
  end

  result = { 'total' => total, 'success' => success, 'pending' => pending, 'failed' => failed, 'frozen' => frozen }
  result['truncated'] = true if truncated
  result
end

.summarize_reviews(reviews) ⇒ Object

Summarizes raw review nodes into { approved, changes_requested, commented, reviewers }.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/hiiro/git/pr.rb', line 162

def self.summarize_reviews(reviews)
  return nil unless reviews.is_a?(Array) && !reviews.empty?

  latest_by_author = {}
  reviews.each do |review|
    author = review.dig('author', 'login')
    next unless author
    state = review['state']
    next unless %w[APPROVED CHANGES_REQUESTED COMMENTED].include?(state)
    latest_by_author[author] = state
  end

  approved          = latest_by_author.values.count { |s| s == 'APPROVED' }
  changes_requested = latest_by_author.values.count { |s| s == 'CHANGES_REQUESTED' }
  commented         = latest_by_author.values.count { |s| s == 'COMMENTED' }

  { 'approved' => approved, 'changes_requested' => changes_requested,
    'commented' => commented, 'reviewers' => latest_by_author }
end

Instance Method Details

#active?Boolean

Aliases matching filter option names

Returns:

  • (Boolean)


236
# File 'lib/hiiro/git/pr.rb', line 236

def active?    = !merged? && !closed?

#checkoutObject



258
# File 'lib/hiiro/git/pr.rb', line 258

def checkout = system('gh', 'pr', 'checkout', number.to_s)

#closeObject



267
# File 'lib/hiiro/git/pr.rb', line 267

def close  = system('gh', 'pr', 'close', number.to_s)

#closed?Boolean

Returns:

  • (Boolean)


225
# File 'lib/hiiro/git/pr.rb', line 225

def closed?      = state&.upcase == 'CLOSED'

#conflicting?Boolean

Returns:

  • (Boolean)


228
# File 'lib/hiiro/git/pr.rb', line 228

def conflicting? = mergeable == 'CONFLICTING'

#conflicts?Boolean

Returns:

  • (Boolean)


238
# File 'lib/hiiro/git/pr.rb', line 238

def conflicts? = conflicting?

#draft?Boolean

Returns:

  • (Boolean)


227
# File 'lib/hiiro/git/pr.rb', line 227

def draft?       = is_draft == true

#drafts?Boolean

Returns:

  • (Boolean)


237
# File 'lib/hiiro/git/pr.rb', line 237

def drafts?    = draft?

#green?Boolean

Returns:

  • (Boolean)


232
# File 'lib/hiiro/git/pr.rb', line 232

def green?   = (c = checks) && c['failed'].to_i == 0 && c['pending'].to_i == 0 && c['success'].to_i > 0

#matches_filters?(opts, forced: []) ⇒ Boolean

Returns true if this PR satisfies the filter options set in opts. forced: injects additional filter keys as if the user had set them.

Returns:

  • (Boolean)


247
248
249
250
251
252
253
254
255
# File 'lib/hiiro/git/pr.rb', line 247

def matches_filters?(opts, forced: [])
  state_active = STATE_FILTER_KEYS.select { |k| forced.include?(k) || (opts.respond_to?(k) && opts.send(k)) }
  check_active = CHECK_FILTER_KEYS.select { |k| forced.include?(k) || (opts.respond_to?(k) && opts.send(k)) }

  state_match = state_active.empty? || state_active.any? { |k| send(:"#{k}?") }
  check_match = check_active.empty? || check_active.any? { |k| send(:"#{k}?") }

  state_match && check_match
end

#merge(method: nil, delete_branch: true) ⇒ Object



260
261
262
263
264
265
# File 'lib/hiiro/git/pr.rb', line 260

def merge(method: nil, delete_branch: true)
  args = ['gh', 'pr', 'merge', number.to_s]
  args << "--#{method}" if method
  args << '--delete-branch' if delete_branch
  system(*args)
end

#merged?Boolean

Returns:

  • (Boolean)


226
# File 'lib/hiiro/git/pr.rb', line 226

def merged?      = state&.upcase == 'MERGED'

#open?Boolean

Returns:

  • (Boolean)


224
# File 'lib/hiiro/git/pr.rb', line 224

def open?        = state&.upcase == 'OPEN'

#pending?Boolean

Returns:

  • (Boolean)


233
# File 'lib/hiiro/git/pr.rb', line 233

def pending? = (c = checks) && c['pending'].to_i > 0 && c['failed'].to_i == 0

#red?Boolean

Check-status predicates

Returns:

  • (Boolean)


231
# File 'lib/hiiro/git/pr.rb', line 231

def red?     = (c = checks) && c['failed'].to_i > 0

#reopenObject



268
# File 'lib/hiiro/git/pr.rb', line 268

def reopen = system('gh', 'pr', 'reopen', number.to_s)

#to_hObject



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/hiiro/git/pr.rb', line 301

def to_h
  {
    number:          number,
    title:           title,
    state:           state,
    url:             url,
    head_branch:     head_branch,
    base_branch:     base_branch,
    repo:            repo,
    slot:            slot,
    is_draft:        is_draft,
    mergeable:       mergeable,
    review_decision: review_decision,
    checks:          checks,
    reviews:         reviews,
    last_checked:    last_checked,
    pinned_at:       pinned_at,
    updated_at:      updated_at,
    task:            task,
    worktree:        worktree,
    tmux_session:    tmux_session,
    tags:            tags,
    assigned:        assigned,
    authored:        authored,
  }.compact
end

#to_pinned_hObject

Serialize back to string-keyed hash for YAML storage.



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/hiiro/git/pr.rb', line 271

def to_pinned_h
  {
    'number'              => number,
    'title'               => title,
    'state'               => state,
    'url'                 => url,
    'headRefName'         => head_branch,
    'repo'                => repo,
    'slot'                => slot,
    'is_draft'            => is_draft,
    'mergeable'           => mergeable,
    'review_decision'     => review_decision,
    'checks'              => checks,
    'statusCheckRollup'   => check_runs,
    'reviews'             => reviews,
    'last_checked'        => last_checked,
    'pinned_at'           => pinned_at,
    'updated_at'          => updated_at,
    'task'                => task,
    'worktree'            => worktree,
    'tmux_session'        => tmux_session,
    'tags'                => (Array(tags).empty? ? nil : tags),
    'assigned'            => assigned,
    'authored'            => authored,
    'depends_on'          => (Array(depends_on).empty? ? nil : depends_on),
  }.compact
end

#to_sObject



299
# File 'lib/hiiro/git/pr.rb', line 299

def to_s = "##{number}: #{title}"

#viewObject



257
# File 'lib/hiiro/git/pr.rb', line 257

def view     = system('gh', 'pr', 'view', number.to_s)