Class: Agentilda::GitHub

Inherits:
Object
  • Object
show all
Defined in:
lib/agentilda/github.rb

Overview

The gh CLI, wrapped thinly.

It is a seam rather than a convenience: every example in the suite injects a double here, so nothing in the tests reaches the network or a real repository.

Constant Summary collapse

FIELDS =

Fields asked of gh pr list.

%w[number title url headRefName files state isDraft mergedAt].freeze
VIEW_FIELDS =

Fields asked of gh pr view, which unlike pr list can be told about one pull request in another repository.

%w[number title url state isDraft mergedAt body].freeze
REF =

A reference to one pull request: a bare number, a #-prefixed number, or a full URL to a GitHub pull request or a GitLab merge request.

%r{\A(?:\#?\d+|https?://\S+?/(?:pull|merge_requests)/\d+/?)\z}

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(command: TTY::Command.new(printer: :null), limit: 200) ⇒ GitHub

Returns a new instance of GitHub.

Parameters:

  • command (TTY::Command) (defaults to: TTY::Command.new(printer: :null))

    runner, injectable for tests

  • limit (Integer) (defaults to: 200)

    how many pull requests to fetch



17
18
19
20
# File 'lib/agentilda/github.rb', line 17

def initialize(command: TTY::Command.new(printer: :null), limit: 200)
  @command = command
  @limit = limit
end

Class Method Details

.parse_refs(text) ⇒ Array<String>

Split and validate a --prs value before any of it reaches the network, so a typo fails in a hundredth of a second with the offending token named rather than after four round trips with a gh diagnostic.

Parameters:

Returns:

  • (Array<String>)

    references, in the order given, de-duplicated

Raises:



86
87
88
89
90
91
92
93
94
95
96
# File 'lib/agentilda/github.rb', line 86

def self.parse_refs(text)
  refs = text.to_s.split(",").map(&:strip).reject(&:empty?)
  raise Error, "no pull requests given" if refs.empty?

  bad = refs.reject { |r| r.match?(REF) }
  unless bad.empty?
    raise Error, "not a pull request number or URL: #{bad.join(", ")}"
  end

  refs.uniq
end

.state_label(pr) ⇒ String

gh speaks in enums; pull-requests.md speaks in the words the state machine parses. Translate once, here, rather than at each call site.

Parameters:

  • pr (Hash)

    a decoded gh pr view payload

Returns:



133
134
135
136
137
138
139
140
141
142
# File 'lib/agentilda/github.rb', line 133

def self.state_label(pr)
  return "Merged 🟣" if pr["mergedAt"]
  return "WIP 🟡" if pr["isDraft"]

  case pr["state"].to_s.upcase
  when "OPEN" then "Open 🟡"
  when "CLOSED" then "Closed 🔴"
  else "Unknown"
  end
end

Instance Method Details

#available?Boolean

Returns whether gh is installed and authenticated.

Returns:

  • (Boolean)

    whether gh is installed and authenticated



156
157
158
# File 'lib/agentilda/github.rb', line 156

def available?
  @command.run!("gh", "auth", "status").success?
end

#no_output_messageString

Returns the diagnosis for a silent gh.

Returns:

  • (String)

    the diagnosis for a silent gh



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/agentilda/github.rb', line 55

def no_output_message
  <<~MESSAGE.strip
    `gh` produced no output and did not report an error.

    That is almost always authentication rather than an empty repository:

      - Check `gh auth status`. An invalid GH_TOKEN in the environment
        shadows a working keyring login and fails without saying so.

      - A non-interactive shell may have no access to the system keyring
        even when an interactive one does.

    Verify with: gh pr list --state all --limit 1
  MESSAGE
end

#pull_request(ref) ⇒ Hash

One pull request, by number or URL.

Parameters:

Returns:

  • (Hash)

    {number:, title:, url:, state:, body:}

Raises:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/agentilda/github.rb', line 103

def pull_request(ref)
  out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
  raise Error, no_output_message if out.to_s.strip.empty?

  pr = JSON.parse(out)
  {
    number: pr["number"],
    title: pr["title"].to_s,
    url: pr["url"].to_s,
    state: self.class.state_label(pr),
    body: pr["body"].to_s
  }
rescue TTY::Command::ExitError, JSON::ParserError => e
  raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
end

#pull_requests(refs) ⇒ Array<Hash>

Several pull requests, in the order asked for.

Parameters:

  • refs (Array<String>)

Returns:

  • (Array<Hash>)


123
124
125
126
# File 'lib/agentilda/github.rb', line 123

def pull_requests(refs)
  UI.stepping(refs, "Fetching pull requests") { |ref| ref }
  refs.map { |ref| pull_request(ref) }
end

#pulls(state: "all") ⇒ Array<Hash>

Every pull request, normalised into plain hashes.

Parameters:

  • state (String) (defaults to: "all")

    "open", "closed", "merged" or "all"

Returns:

  • (Array<Hash>)

    {number:, title:, url:, branch:, files:}



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/agentilda/github.rb', line 26

def pulls(state: "all")
  out = UI.spinning("Fetching pull requests from GitHub") {
    @command.run("gh", "pr", "list", "--state", state, "--limit", @limit.to_s,
      "--json", FIELDS.join(",")).out
  }

  # `gh` can exit 0 having printed NOTHING — most often when it cannot reach
  # the credential store, as in a non-interactive shell that has no keyring
  # access, or when GH_TOKEN is set to something invalid and shadows a
  # working login. Left alone this parses as a JSON error and reports as
  # "bad output", sending you to look at the wrong thing entirely.
  raise Error, no_output_message if out.to_s.strip.empty?

  JSON.parse(out).map do |pr|
    {
      number: pr["number"],
      title: pr["title"].to_s,
      url: pr["url"],
      branch: pr["headRefName"].to_s,
      files: Array(pr["files"]).map { |f| f["path"] }.compact,
      state: self.class.state_label(pr),
      open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
    }
  end
rescue TTY::Command::ExitError, JSON::ParserError => e
  raise Error, "could not list pull requests via `gh`: #{e.message.lines.first.to_s.strip}"
end

#retitle(number:, title:) ⇒ void

This method returns an undefined value.

Change a pull request's title.

Parameters:

  • number (Integer)
  • title (String)


149
150
151
152
153
# File 'lib/agentilda/github.rb', line 149

def retitle(number:, title:)
  @command.run("gh", "pr", "edit", number.to_s, "--title", title)
rescue TTY::Command::ExitError => e
  raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
end