sferik 0.0.1

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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +5 -0
  3. data/CHANGELOG.md +72 -0
  4. data/LICENSE.md +16 -0
  5. data/README.md +517 -0
  6. data/exe/sferik +6 -0
  7. data/lib/sferik/api/code_endpoints.rb +37 -0
  8. data/lib/sferik/api/profile_endpoints.rb +113 -0
  9. data/lib/sferik/api/resume_endpoints.rb +41 -0
  10. data/lib/sferik/api/site_endpoints.rb +247 -0
  11. data/lib/sferik/api/talk_endpoints.rb +45 -0
  12. data/lib/sferik/api.rb +23 -0
  13. data/lib/sferik/block.rb +25 -0
  14. data/lib/sferik/body.rb +54 -0
  15. data/lib/sferik/cache.rb +361 -0
  16. data/lib/sferik/cli.rb +369 -0
  17. data/lib/sferik/client.rb +453 -0
  18. data/lib/sferik/collection.rb +82 -0
  19. data/lib/sferik/configuration.rb +129 -0
  20. data/lib/sferik/connections.rb +151 -0
  21. data/lib/sferik/contributions.rb +86 -0
  22. data/lib/sferik/day.rb +35 -0
  23. data/lib/sferik/dependency.rb +26 -0
  24. data/lib/sferik/deployment.rb +36 -0
  25. data/lib/sferik/errors.rb +234 -0
  26. data/lib/sferik/figure.rb +83 -0
  27. data/lib/sferik/finger.rb +74 -0
  28. data/lib/sferik/freshness.rb +54 -0
  29. data/lib/sferik/home/pages.rb +27 -0
  30. data/lib/sferik/home/profile.rb +51 -0
  31. data/lib/sferik/home/section.rb +27 -0
  32. data/lib/sferik/home.rb +38 -0
  33. data/lib/sferik/json_parsing.rb +28 -0
  34. data/lib/sferik/name_change.rb +49 -0
  35. data/lib/sferik/place.rb +33 -0
  36. data/lib/sferik/podcast.rb +41 -0
  37. data/lib/sferik/project.rb +49 -0
  38. data/lib/sferik/projects.rb +138 -0
  39. data/lib/sferik/push.rb +33 -0
  40. data/lib/sferik/resource.rb +373 -0
  41. data/lib/sferik/resume/award.rb +43 -0
  42. data/lib/sferik/resume/basics.rb +77 -0
  43. data/lib/sferik/resume/education.rb +59 -0
  44. data/lib/sferik/resume/location.rb +35 -0
  45. data/lib/sferik/resume/meta.rb +35 -0
  46. data/lib/sferik/resume/patent.rb +43 -0
  47. data/lib/sferik/resume/profile.rb +35 -0
  48. data/lib/sferik/resume/project.rb +27 -0
  49. data/lib/sferik/resume/skill.rb +27 -0
  50. data/lib/sferik/resume/speaking.rb +19 -0
  51. data/lib/sferik/resume/volunteer.rb +59 -0
  52. data/lib/sferik/resume/work.rb +51 -0
  53. data/lib/sferik/resume.rb +125 -0
  54. data/lib/sferik/session.rb +41 -0
  55. data/lib/sferik/social_profile.rb +49 -0
  56. data/lib/sferik/status/github.rb +38 -0
  57. data/lib/sferik/status/loaded.rb +48 -0
  58. data/lib/sferik/status.rb +33 -0
  59. data/lib/sferik/talk.rb +73 -0
  60. data/lib/sferik/talks.rb +79 -0
  61. data/lib/sferik/validation.rb +119 -0
  62. data/lib/sferik/version.rb +7 -0
  63. data/lib/sferik/web_finger/link.rb +46 -0
  64. data/lib/sferik/web_finger.rb +43 -0
  65. data/lib/sferik/who.rb +53 -0
  66. data/lib/sferik/whoami.rb +36 -0
  67. data/lib/sferik/wrapping.rb +114 -0
  68. data/lib/sferik.rb +246 -0
  69. data/sig/manifest.yaml +13 -0
  70. data/sig/sferik.rbs +825 -0
  71. metadata +118 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+ require_relative "social_profile"
5
+
6
+ module Sferik
7
+ # Contact details and profiles, as returned by {API::ProfileEndpoints#finger}
8
+ # @api public
9
+ class Finger < Resource
10
+ # @!method name
11
+ # The full name
12
+ # @api public
13
+ # @return [String] the full name
14
+ # @example
15
+ # finger.name # => "Erik Berlin"
16
+ attribute :name
17
+
18
+ # @!method login
19
+ # The handle
20
+ # @api public
21
+ # @return [String] the handle
22
+ # @example
23
+ # finger.login # => "sferik"
24
+ attribute :login
25
+
26
+ # @!method mail
27
+ # The email address
28
+ # @api public
29
+ # @return [String] the email address
30
+ # @example
31
+ # finger.mail # => "sferik@gmail.com"
32
+ attribute :mail
33
+
34
+ # @!method directory
35
+ # The home directory, in the site's shell
36
+ # @api public
37
+ # @return [String] the home directory, in the site's shell
38
+ # @example
39
+ # finger.directory # => "/Users/sferik"
40
+ attribute :directory
41
+
42
+ # @!method shell
43
+ # The login shell, in the site's shell
44
+ # @api public
45
+ # @return [String] the login shell, in the site's shell
46
+ # @example
47
+ # finger.shell # => "/opt/homebrew/bin/fish"
48
+ attribute :shell
49
+
50
+ # @!method plan
51
+ # The .plan
52
+ # @api public
53
+ # @return [String] the .plan
54
+ # @example
55
+ # finger.plan # => "..."
56
+ attribute :plan
57
+
58
+ # @!method profiles
59
+ # Profiles elsewhere
60
+ # @api public
61
+ # @return [Array<SocialProfile>] profiles elsewhere
62
+ # @example
63
+ # finger.profiles # => [#<Sferik::SocialProfile ...>, ...]
64
+ list :profiles, "profiles", type: SocialProfile
65
+
66
+ # @!method command
67
+ # The shell command the home page shows it as
68
+ # @api public
69
+ # @return [String] the shell command the home page shows it as
70
+ # @example
71
+ # finger.command # => "finger sferik"
72
+ attribute :command
73
+ end
74
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sferik
4
+ # Reads what an answer says about keeping a response: for how long it's good, and whether to keep it at all
5
+ #
6
+ # An answer is the response itself, or a 304 that says a response kept hasn't changed. It says how long the response
7
+ # is good for (Cache-Control: max-age), and how long it has been kept somewhere on its way already (Age), as one
8
+ # from Cloudflare's cache has.
9
+ #
10
+ # @api private
11
+ module Freshness
12
+ # The seconds a Cache-Control says a response is good for
13
+ MAX_AGE = /\bmax-age=(\d+)/i
14
+ private_constant :MAX_AGE
15
+
16
+ # A Cache-Control that says to check a response each time it's used
17
+ NO_CACHE = /\bno-cache\b/i
18
+ private_constant :NO_CACHE
19
+
20
+ # A Cache-Control that says not to keep a response
21
+ NO_STORE = /\bno-store\b/i
22
+ private_constant :NO_STORE
23
+
24
+ # The seconds an answer says a response is good for
25
+ #
26
+ # @api private
27
+ # @param answer [Net::HTTPResponse] the answer
28
+ # @return [Integer] the seconds: none if it says to check each time, or doesn't say
29
+ def self.lifetime(answer)
30
+ control = answer["cache-control"].to_s
31
+ (control[MAX_AGE, 1] unless NO_CACHE.match?(control)).to_i
32
+ end
33
+
34
+ # The seconds a response is still good for, when its answer comes
35
+ #
36
+ # @api private
37
+ # @param answer [Net::HTTPResponse] the answer
38
+ # @return [Integer] the seconds it's good for, less the whole seconds it says it has been kept already: none, or
39
+ # fewer, for one that's no longer good
40
+ def self.left(answer)
41
+ lifetime(answer) - answer["age"].to_i
42
+ end
43
+
44
+ # Whether an answer lets a response be kept
45
+ #
46
+ # @api private
47
+ # @param answer [Net::HTTPResponse] the answer
48
+ # @return [Boolean] false if it says not to keep the response
49
+ def self.keepable?(answer)
50
+ !answer["cache-control"]&.match?(NO_STORE)
51
+ end
52
+ end
53
+ private_constant :Freshness
54
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../resource"
4
+
5
+ module Sferik
6
+ class Home < Resource
7
+ # The paths of the site's other pages, as returned in {Home#pages}
8
+ # @api public
9
+ class Pages < Resource
10
+ # @!method talks
11
+ # The talks page
12
+ # @api public
13
+ # @return [String] the talks page
14
+ # @example
15
+ # pages.talks # => "/talks"
16
+ attribute :talks
17
+
18
+ # @!method resume
19
+ # The resume page
20
+ # @api public
21
+ # @return [String] the resume page
22
+ # @example
23
+ # pages.resume # => "/resume"
24
+ attribute :resume
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../resource"
4
+
5
+ module Sferik
6
+ class Home < Resource
7
+ # Who the home page is about, as returned in {Home#profile}
8
+ # @api public
9
+ class Profile < Resource
10
+ # @!method name
11
+ # The name
12
+ # @api public
13
+ # @return [String] the name
14
+ # @example
15
+ # profile.name # => "Erik Berlin"
16
+ attribute :name
17
+
18
+ # @!method location
19
+ # Where Erik lives
20
+ # @api public
21
+ # @return [String] where Erik lives
22
+ # @example
23
+ # profile.location # => "San Francisco, California"
24
+ attribute :location
25
+
26
+ # @!method tagline
27
+ # The tagline
28
+ # @api public
29
+ # @return [String] the tagline
30
+ # @example
31
+ # profile.tagline # => "I build libraries and tools software engineers depend on."
32
+ attribute :tagline
33
+
34
+ # @!method handle
35
+ # The handle used everywhere
36
+ # @api public
37
+ # @return [String] the handle used everywhere
38
+ # @example
39
+ # profile.handle # => "sferik"
40
+ attribute :handle
41
+
42
+ # @!method url
43
+ # The site
44
+ # @api public
45
+ # @return [String] the site
46
+ # @example
47
+ # profile.url # => "https://sferik.net"
48
+ attribute :url
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../resource"
4
+
5
+ module Sferik
6
+ class Home < Resource
7
+ # One of the home page's modules, as returned in {Home#modules}
8
+ # @api public
9
+ class Section < Resource
10
+ # @!method id
11
+ # The module's name
12
+ # @api public
13
+ # @return [String] the module's name
14
+ # @example
15
+ # section.id # => "whoami"
16
+ attribute :id
17
+
18
+ # @!method url
19
+ # Its path, which {Client#get} takes
20
+ # @api public
21
+ # @return [String] its path, which {Client#get} takes
22
+ # @example
23
+ # section.url # => "/whoami"
24
+ attribute :url
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+ require_relative "home/pages"
5
+ require_relative "home/profile"
6
+ require_relative "home/section"
7
+
8
+ module Sferik
9
+ # The profile and the home page's modules, as returned by {API::ProfileEndpoints#home}
10
+ # @api public
11
+ class Home < Resource
12
+ # @!method profile
13
+ # Who the site is about
14
+ # @api public
15
+ # @return [Profile] who the site is about
16
+ # @example
17
+ # home.profile # => #<Sferik::Home::Profile ...>
18
+ attribute :profile, "profile", type: Profile
19
+
20
+ # @!method modules
21
+ # The home page's modules, in order
22
+ # @api public
23
+ # @return [Array<Section>] the home page's modules, in order
24
+ # @example
25
+ # home.modules # => [#<Sferik::Home::Section id="whoami" ...>, ...]
26
+ list :modules, "modules", type: Section
27
+
28
+ # @!method pages
29
+ # The paths of the other pages
30
+ # @api public
31
+ # @return [Pages] the paths of the other pages
32
+ # @example
33
+ # home.pages # => #<Sferik::Home::Pages ...>
34
+ attribute :pages, "pages", type: Pages
35
+
36
+ inspect_with :profile
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+
6
+ module Sferik
7
+ # Parses the API's JSON responses, for the endpoints that return objects
8
+ # @api private
9
+ module JSONParsing
10
+ private
11
+
12
+ # Parse a JSON response body
13
+ #
14
+ # @api private
15
+ # @param body [String] the response body
16
+ # @return [Hash] the parsed body, deep-frozen
17
+ # @raise [InvalidResponse] if the body isn't a JSON object, or isn't in the charset the response names (or one JSON can be read in)
18
+ def parse_json(body)
19
+ value = JSON.parse(body, freeze: true)
20
+ raise InvalidResponse, "Expected a JSON object, got #{value.class}" unless value.instance_of?(Hash)
21
+
22
+ value
23
+ rescue JSON::ParserError, EncodingError => e
24
+ raise InvalidResponse, "Couldn't parse the response as JSON: #{e}"
25
+ end
26
+ end
27
+ private_constant :JSONParsing
28
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Sferik
6
+ # The name change, as a git commit, as returned by {API::ProfileEndpoints#name_change}
7
+ # @api public
8
+ class NameChange < Resource
9
+ # @!method subject
10
+ # The commit's subject
11
+ # @api public
12
+ # @return [String] the commit's subject
13
+ # @example
14
+ # name_change.subject # => "Rename Erik Michaels-Ober to Erik Berlin"
15
+ attribute :subject
16
+
17
+ # @!method year
18
+ # The year of the change
19
+ # @api public
20
+ # @return [Integer] the year of the change
21
+ # @example
22
+ # name_change.year # => 2017
23
+ attribute :year
24
+
25
+ # @!method commit
26
+ # The commit's abbreviated hash
27
+ # @api public
28
+ # @return [String] the commit's abbreviated hash
29
+ # @example
30
+ # name_change.commit # => "8c0d698"
31
+ attribute :commit
32
+
33
+ # @!method notes
34
+ # More about it, as HTML
35
+ # @api public
36
+ # @return [Array<String>] more about it, as HTML
37
+ # @example
38
+ # name_change.notes # => ["..."]
39
+ list :notes
40
+
41
+ # @!method command
42
+ # The shell command the home page shows it as
43
+ # @api public
44
+ # @return [String] the shell command the home page shows it as
45
+ # @example
46
+ # name_change.command # => "git log --follow --oneline -- name"
47
+ attribute :command
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Sferik
6
+ # Where a talk's location is, as returned in {Talks#places}
7
+ # @api public
8
+ class Place < Resource
9
+ # @!method lat
10
+ # The latitude, in degrees
11
+ # @api public
12
+ # @return [Numeric] the latitude, in degrees
13
+ # @example
14
+ # place.lat # => 41.39
15
+ attribute :lat
16
+
17
+ # @!method lon
18
+ # The longitude, in degrees
19
+ # @api public
20
+ # @return [Numeric] the longitude, in degrees
21
+ # @example
22
+ # place.lon # => 2.17
23
+ attribute :lon
24
+
25
+ # @!method country
26
+ # The country it's in
27
+ # @api public
28
+ # @return [String] the country it's in
29
+ # @example
30
+ # place.country # => "Spain"
31
+ attribute :country
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Sferik
6
+ # A podcast appearance, as returned in {Talks#podcasts}
7
+ # @api public
8
+ class Podcast < Resource
9
+ # @!method title
10
+ # The episode's title
11
+ # @api public
12
+ # @return [String] the episode's title
13
+ # @example
14
+ # podcast.title # => "The Crystal Programming Language"
15
+ attribute :title
16
+
17
+ # @!method show
18
+ # The podcast
19
+ # @api public
20
+ # @return [String] the podcast
21
+ # @example
22
+ # podcast.show # => "Ruby Rogues, episode 248"
23
+ attribute :show
24
+
25
+ # @!method date
26
+ # When it was, to the month: the first day of that month
27
+ # @api public
28
+ # @return [Date] the first day of the month it was in
29
+ # @example
30
+ # podcast.date # => #<Date: 2016-02-01>
31
+ timestamp :date, PartialDate
32
+
33
+ # @!method url
34
+ # The episode
35
+ # @api public
36
+ # @return [String] the episode
37
+ # @example
38
+ # podcast.url # => "https://..."
39
+ attribute :url
40
+ end
41
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Sferik
6
+ # A project, as returned in {Projects#projects}
7
+ # @api public
8
+ class Project < Resource
9
+ # @!method name
10
+ # The project's name
11
+ # @api public
12
+ # @return [String] the project's name
13
+ # @example
14
+ # project.name # => "multi_json"
15
+ attribute :name
16
+
17
+ # @!method description
18
+ # What it is
19
+ # @api public
20
+ # @return [String] what it is
21
+ # @example
22
+ # project.description # => "One interface to every Ruby JSON library."
23
+ attribute :description
24
+
25
+ # @!method downloads
26
+ # Its RubyGems downloads, or nil if it isn't a gem
27
+ # @api public
28
+ # @return [Integer, nil] its RubyGems downloads, or nil if it isn't a gem
29
+ # @example
30
+ # project.downloads # => 1_170_000_000
31
+ attribute :downloads
32
+
33
+ # @!method stars
34
+ # Its GitHub stars, or nil if it isn't on GitHub
35
+ # @api public
36
+ # @return [Integer, nil] its GitHub stars, or nil if it isn't on GitHub
37
+ # @example
38
+ # project.stars # => 27
39
+ attribute :stars
40
+
41
+ # @!method url
42
+ # Its home page
43
+ # @api public
44
+ # @return [String] its home page
45
+ # @example
46
+ # project.url # => "https://github.com/sferik/multi_json"
47
+ attribute :url
48
+ end
49
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+ require_relative "collection"
5
+ require_relative "project"
6
+
7
+ module Sferik
8
+ # Projects with their downloads and stars, as returned by {API::CodeEndpoints#projects}
9
+ #
10
+ # It's Enumerable over its {#projects}, so `Sferik.projects.first` is the most downloaded, and
11
+ # `Sferik.projects.map(&:name)` lists them all. It also has what an Array adds to that: {#size}, {#length},
12
+ # {#empty?}, {#last}, and {#[]}.
13
+ #
14
+ # @api public
15
+ class Projects < Resource
16
+ include Enumerable
17
+ include Collection
18
+
19
+ # ActiveSupport's Enumerable#as_json would make a list of them; keep the JSON it came from, as for any resource
20
+ define_method(:as_json, Resource.instance_method(:as_json))
21
+
22
+ # @!method projects
23
+ # The projects, most downloaded first, with related ones together
24
+ # @api public
25
+ # @return [Array<Project>] the projects, most downloaded first, with related ones together
26
+ # @example
27
+ # projects.projects # => [#<Sferik::Project ...>, ...]
28
+ list :projects, "projects", type: Project
29
+
30
+ # The RubyGems downloads of every gem @sferik owns, not only those listed
31
+ #
32
+ # @api public
33
+ # @return [Integer, nil] the RubyGems downloads of every gem @sferik owns, not only those listed
34
+ # @example
35
+ # projects.total_downloads # => 5_460_234_129
36
+ def total_downloads
37
+ total["downloads"]
38
+ end
39
+ record :total_downloads
40
+
41
+ # The number of gems @sferik owns
42
+ #
43
+ # @api public
44
+ # @return [Integer, nil] the number of gems @sferik owns
45
+ # @example
46
+ # projects.total_gems # => 65
47
+ def total_gems
48
+ total["gems"]
49
+ end
50
+ record :total_gems
51
+
52
+ # The GitHub stars of the projects listed
53
+ #
54
+ # @api public
55
+ # @return [Integer, nil] the GitHub stars of the projects listed
56
+ # @example
57
+ # projects.total_stars # => 38_000
58
+ def total_stars
59
+ total["stars"]
60
+ end
61
+ record :total_stars
62
+
63
+ # @!method more
64
+ # Where to find the rest
65
+ # @api public
66
+ # @return [String] where to find the rest
67
+ # @example
68
+ # projects.more # => "https://github.com/sferik"
69
+ attribute :more
70
+
71
+ # @!method live?
72
+ # Whether the downloads are what RubyGems says now
73
+ #
74
+ # They aren't when they're the snapshot, which the site falls back to, or the last that were fetched, more than
75
+ # two hours ago, which it goes on with when it can't fetch them again: {#as_of} says when they're from.
76
+ #
77
+ # @api public
78
+ # @return [Boolean] false when the downloads are a snapshot, or were last fetched more than two hours ago
79
+ # @example
80
+ # projects.live? # => true
81
+ predicate :live
82
+
83
+ # @!method as_of
84
+ # When the downloads are from
85
+ #
86
+ # That's the hour they were fetched in, or the day of the snapshot, if they're that. It's to the hour so that
87
+ # downloads that are fetched again, and haven't changed, are the same response, which a {Client#cached} client
88
+ # isn't sent again: {Status::Loaded#gems} is when they were fetched, to the second.
89
+ #
90
+ # @api public
91
+ # @return [Time] when the downloads are from
92
+ # @example
93
+ # projects.as_of # => 2026-10-08 01:00:00 UTC
94
+ timestamp :as_of, Time
95
+
96
+ # @!method command
97
+ # The shell command the home page shows it as
98
+ # @api public
99
+ # @return [String] the shell command the home page shows it as
100
+ # @example
101
+ # projects.command # => "ls -lS ~/src"
102
+ attribute :command
103
+
104
+ inspect_with :size, :total_downloads, :total_stars
105
+
106
+ # Yield each project, most downloaded first (related projects together)
107
+ #
108
+ # @api public
109
+ # @yieldparam project [Project] a project
110
+ # @return [self, Enumerator<Project>] the projects themselves, or an Enumerator without a block
111
+ # @example
112
+ # Sferik.projects.each { |project| puts project.name }
113
+ def each(&block)
114
+ return to_enum { size } unless block
115
+
116
+ projects.each(&block)
117
+ self
118
+ end
119
+
120
+ private
121
+
122
+ # The totals, which are empty when the response has none, and are checked when the projects are built
123
+ prepare(:total) do
124
+ value = given(attributes["total"], NONE)
125
+ raise InvalidResponse, "#{self.class}#total: expected a JSON object, got #{value.class}" unless value.instance_of?(Hash)
126
+
127
+ value
128
+ end
129
+
130
+ # The totals, which are empty when the response has none
131
+ #
132
+ # @api private
133
+ # @return [Hash{String => Object}] the totals
134
+ def total
135
+ prepared(:total)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Sferik
6
+ # A push to GitHub, as returned by {Contributions#last_push}
7
+ # @api public
8
+ class Push < Resource
9
+ # @!method repo
10
+ # The repository
11
+ # @api public
12
+ # @return [String] the repository
13
+ # @example
14
+ # push.repo # => "sferik/x-ruby"
15
+ attribute :repo
16
+
17
+ # @!method sha
18
+ # The commit at the head of the push
19
+ # @api public
20
+ # @return [String] the commit at the head of the push
21
+ # @example
22
+ # push.sha # => "abc1234def5678"
23
+ attribute :sha
24
+
25
+ # @!method at
26
+ # When it was pushed
27
+ # @api public
28
+ # @return [Time] when it was pushed
29
+ # @example
30
+ # push.at # => 2026-10-01 12:00:00 UTC
31
+ timestamp :at, Time
32
+ end
33
+ end