main
rb 126 lines 3.92 KB
Raw
1 # frozen_string_literal: true
2
3 require "net/http"
4 require "json"
5
6 # Thin GitHub REST client bound to one user's OAuth token, used to list repos
7 # and read repo metadata for the import flow. Intentionally small — we only need
8 # a few read endpoints, so this avoids pulling in Octokit.
9 #
10 # The token is held only in memory on this instance and sent as a bearer header;
11 # it is never logged. Rate limits are respected: on a 403/429 with the limit
12 # exhausted we raise RateLimited carrying the reset time so the caller can back
13 # off rather than hammer GitHub.
14 class GithubApiClient
15 API_ROOT = "https://api.github.com"
16
17 OPEN_TIMEOUT = 5
18 READ_TIMEOUT = 15
19
20 class Error < StandardError; end
21 class Unauthorized < Error; end
22 class RateLimited < Error
23 attr_reader :reset_at
24 def initialize(message, reset_at:)
25 super(message)
26 @reset_at = reset_at
27 end
28 end
29
30 def initialize(access_token)
31 @access_token = access_token
32 end
33
34 # The authenticated user's login, or nil if the token is bad. Used to label
35 # the connection and build clone URLs.
36 def login
37 me = get("/user")
38 me["login"]
39 rescue Unauthorized
40 nil
41 end
42
43 # Lists the authenticated user's repositories, newest-pushed first. Paginates
44 # internally up to +max+ repos (a safety cap so a user with thousands of repos
45 # can't make us page forever). Returns an array of normalized hashes.
46 def list_repositories(max: 300, per_page: 50)
47 repos = []
48 page = 1
49 loop do
50 batch = get("/user/repos", affiliation: "owner,collaborator",
51 sort: "pushed", per_page: per_page, page: page)
52 break if batch.blank?
53
54 repos.concat(batch.map { |r| normalize_repo(r) })
55 break if batch.length < per_page || repos.length >= max
56
57 page += 1
58 end
59 repos.first(max)
60 end
61
62 # Metadata for a single repo ("owner/name"), normalized. nil if not found.
63 def repository(full_name)
64 normalize_repo(get("/repos/#{full_name}"))
65 rescue Error
66 nil
67 end
68
69 private
70
71 def normalize_repo(r)
72 {
73 full_name: r["full_name"],
74 name: r["name"],
75 description: r["description"],
76 private: r["private"],
77 default_branch: r["default_branch"],
78 clone_url: r["clone_url"],
79 homepage: r["homepage"],
80 topics: r["topics"] || [],
81 size_kb: r["size"], # GitHub reports size in KB
82 pushed_at: r["pushed_at"],
83 archived: r["archived"],
84 fork: r["fork"]
85 }
86 end
87
88 def get(path, **query)
89 uri = URI.parse("#{API_ROOT}#{path}")
90 uri.query = URI.encode_www_form(query) if query.any?
91
92 req = Net::HTTP::Get.new(uri)
93 req["Authorization"] = "Bearer #{@access_token}"
94 req["Accept"] = "application/vnd.github+json"
95 req["X-GitHub-Api-Version"] = "2022-11-28"
96 req["User-Agent"] = "sigit-si-importer"
97
98 res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true,
99 open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
100 http.request(req)
101 end
102
103 handle(res)
104 end
105
106 def handle(res)
107 case res
108 when Net::HTTPSuccess
109 res.body.present? ? JSON.parse(res.body) : nil
110 when Net::HTTPUnauthorized
111 raise Unauthorized, "GitHub token is invalid or expired."
112 when Net::HTTPForbidden, Net::HTTPTooManyRequests
113 # Distinguish a real rate-limit from a permission error by the remaining
114 # header, so we only tell the caller to back off when it actually should.
115 if res["x-ratelimit-remaining"].to_i.zero? && res["x-ratelimit-reset"].present?
116 reset_at = Time.at(res["x-ratelimit-reset"].to_i)
117 raise RateLimited.new("GitHub API rate limit reached.", reset_at: reset_at)
118 end
119 raise Error, "GitHub denied the request (HTTP #{res.code})."
120 else
121 raise Error, "GitHub request failed (HTTP #{res.code})."
122 end
123 rescue JSON::ParserError
124 raise Error, "GitHub returned an unreadable response."
125 end
126 end