actionagent 1.8.0 → 1.9.0

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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -2
@@ -1,66 +1,250 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "openssl"
4
+ require "base64"
5
+
3
6
  module ActionAgent
4
- # The few GitHub calls the dashboard makes: the OAuth code exchange, the
5
- # authenticated user, and the repositories that user can reach. Plain
6
- # Net::HTTP, like the provider model lookups, so the engine carries no
7
- # GitHub SDK.
7
+ # The GitHub calls the dashboard makes, including reading a repository's
8
+ # files without cloning it. Plain Net::HTTP and OpenSSL, like the provider
9
+ # model lookups, so the engine carries no GitHub SDK and no JWT library.
10
+ #
11
+ # Two kinds of credential reach it:
12
+ #
13
+ # - An instance wraps a bearer token: an OAuth App user token, a GitHub
14
+ # App user token (held only while an installation is verified), or an
15
+ # installation token.
16
+ # - The class methods that act as the GitHub App sign a JWT with the
17
+ # App's private key (ActionAgent.github_app_private_key) per request.
8
18
  class GithubClient
9
19
  API = "https://api.github.com"
10
- TOKEN_URL = "https://github.com/login/oauth/access_token"
11
- AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
20
+ WEB = "https://github.com"
21
+ TOKEN_URL = "#{WEB}/login/oauth/access_token"
22
+ AUTHORIZE_URL = "#{WEB}/login/oauth/authorize"
23
+ API_VERSION = "2022-11-28"
24
+ USER_AGENT = "activeagent-dashboard"
12
25
  TIMEOUT_SECONDS = 8
13
26
  # 100 per page is GitHub's maximum; five pages bounds a picker at 500.
14
27
  PER_PAGE = 100
15
28
  MAX_PAGES = 5
29
+ # GitHub refuses an App JWT that expires more than 10 minutes out, or that
30
+ # its own clock sees as issued in the future, so the JWT is backdated a
31
+ # minute and expires nine minutes from now.
32
+ JWT_BACKDATE_SECONDS = 60
33
+ JWT_LIFETIME_SECONDS = 540
34
+ # A user or organization login, as GitHub allows them.
35
+ LOGIN = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\z/
36
+ # "owner/name", as GitHub allows repository names ("." and ".." are not).
37
+ REPOSITORY = %r{\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\.{1,2}\z)[A-Za-z0-9._-]{1,100}\z}
38
+ OBJECT_ID = /\A\h{40}(?:\h{24})?\z/
39
+
40
+ # Files larger than this are not read through the contents API.
41
+ MAX_FILE_BYTES = 512 * 1024
16
42
 
17
- class Error < StandardError; end
43
+ class Error < StandardError
44
+ # The HTTP status GitHub answered with, or nil when it was not reached.
45
+ attr_reader :status
46
+ # The +errors+ of GitHub's answer, each a Hash such as { "resource" =>
47
+ # "PullRequest", "field" => "base", "code" => "invalid", "message" =>
48
+ # "…" }; empty when it carried none.
49
+ attr_reader :errors
50
+
51
+ def initialize(message = nil, status: nil, errors: [])
52
+ super(message)
53
+ @status = status
54
+ @errors = errors
55
+ end
56
+ end
18
57
  # The token was revoked or expired: the owner has to connect again.
19
58
  class Unauthorized < Error; end
59
+ # GitHub answered 404: no such repository, ref or path, or one the token
60
+ # cannot see, which GitHub does not tell apart. Also 409, which GitHub
61
+ # answers for the commits and trees of a repository with no commits.
62
+ class NotFound < Error; end
63
+ # GitHub minted no token because the installation was removed from its
64
+ # account (404) or suspended (403). +reason+ is :removed or :suspended.
65
+ class InstallationUnavailable < Error
66
+ attr_reader :reason
20
67
 
21
- def self.authorize_url(redirect_uri:, state:)
22
- query = {
23
- client_id: ActionAgent.github_client_id,
24
- redirect_uri: redirect_uri,
25
- scope: ActionAgent.github_oauth_scopes,
26
- state: state,
27
- allow_signup: "false"
28
- }.to_query
29
-
30
- "#{AUTHORIZE_URL}?#{query}"
68
+ def initialize(message = nil, reason:, status: nil)
69
+ super(message, status: status)
70
+ @reason = reason
71
+ end
31
72
  end
32
73
 
33
- # Trades an OAuth code for { access_token:, scope: }.
34
- def self.exchange_code(code:, redirect_uri:)
35
- uri = URI.parse(TOKEN_URL)
36
- request = Net::HTTP::Post.new(uri, "Accept" => "application/json")
37
- request.set_form_data(
38
- client_id: ActionAgent.github_client_id,
39
- client_secret: ActionAgent.github_client_secret,
40
- code: code,
41
- redirect_uri: redirect_uri
42
- )
43
- data = perform(uri, request)
44
- raise Error, data["error_description"].presence || data["error"] if data["error"].present?
45
- raise Error, "GitHub returned no access token" if data["access_token"].blank?
74
+ class << self
75
+ # GitHub's authorization page. The OAuth App with its scopes by default.
76
+ # The GitHub App's user authorization passes the App's client id and no
77
+ # scope, since a GitHub App's permissions are set on the App.
78
+ def authorize_url(redirect_uri:, state:, client_id: ActionAgent.github_client_id, scope: ActionAgent.github_oauth_scopes)
79
+ query = {
80
+ client_id: client_id,
81
+ redirect_uri: redirect_uri,
82
+ scope: scope,
83
+ state: state,
84
+ allow_signup: "false"
85
+ }.compact.to_query
46
86
 
47
- { access_token: data["access_token"], scope: data["scope"].to_s }
48
- end
87
+ "#{AUTHORIZE_URL}?#{query}"
88
+ end
49
89
 
50
- def self.perform(uri, request)
51
- response = Net::HTTP.start(
52
- uri.host, uri.port,
53
- use_ssl: true, open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS
54
- ) { |http| http.request(request) }
90
+ # Where an admin installs the GitHub App on the repositories they choose.
91
+ def installation_url(state:, slug: ActionAgent.github_app_slug)
92
+ "#{WEB}/apps/#{ERB::Util.url_encode(slug)}/installations/new?#{{ state: state }.to_query}"
93
+ end
55
94
 
56
- raise Unauthorized, "GitHub rejected the token" if response.code.to_i == 401
57
- raise Error, "GitHub answered #{response.code}" unless response.code.to_i.between?(200, 299)
95
+ # Where a manifest is posted to create a GitHub App: under the signed-in
96
+ # GitHub user, or under +organization+ when one is named.
97
+ def new_app_url(state:, organization: nil)
98
+ path = organization.present? ? "/organizations/#{ERB::Util.url_encode(organization)}/settings/apps/new" : "/settings/apps/new"
99
+ "#{WEB}#{path}?#{{ state: state }.to_query}"
100
+ end
101
+
102
+ # Trades an OAuth code for { access_token:, scope: }, with the OAuth
103
+ # App's credentials unless others are given. A refresh token GitHub
104
+ # sends with an expiring user token is dropped.
105
+ def exchange_code(code:, redirect_uri:, client_id: ActionAgent.github_client_id, client_secret: ActionAgent.github_client_secret)
106
+ uri = URI.parse(TOKEN_URL)
107
+ request = Net::HTTP::Post.new(uri, "Accept" => "application/json", "User-Agent" => USER_AGENT)
108
+ request.set_form_data(client_id: client_id, client_secret: client_secret, code: code, redirect_uri: redirect_uri)
109
+ data = perform(uri, request)
110
+ raise Error, data["error_description"].presence || data["error"] if data["error"].present?
111
+ raise Error, "GitHub returned no access token" if data["access_token"].blank?
112
+
113
+ { access_token: data["access_token"], scope: data["scope"].to_s }
114
+ end
58
115
 
59
- JSON.parse(response.body.presence || "{}")
60
- rescue JSON::ParserError
61
- raise Error, "GitHub answered with something other than JSON"
62
- rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
63
- raise Error, "GitHub is unreachable (#{e.class.name})"
116
+ # A JWT that authenticates as the GitHub App: RS256 over the App's
117
+ # private key, issued by the App id.
118
+ #
119
+ # @raise [Error] when the private key is missing or not an RSA key
120
+ # @return [String]
121
+ def app_jwt(now: Time.now)
122
+ payload = { iat: now.to_i - JWT_BACKDATE_SECONDS, exp: now.to_i + JWT_LIFETIME_SECONDS, iss: ActionAgent.github_app_id }
123
+ signing_input = [ { alg: "RS256", typ: "JWT" }, payload ].map { |part| base64url(part.to_json) }.join(".")
124
+ signature = app_private_key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
125
+
126
+ "#{signing_input}.#{base64url(signature)}"
127
+ end
128
+
129
+ # Mints an installation token, valid for an hour, limited to
130
+ # +permissions+ (e.g. { contents: "read" }) and, when given, to
131
+ # +repository_ids+. The token is returned and kept nowhere.
132
+ #
133
+ # @raise [InstallationUnavailable] when the installation was removed or suspended
134
+ # @return [String]
135
+ def mint_installation_token(installation_id, permissions:, repository_ids: nil)
136
+ body = { permissions: permissions, repository_ids: repository_ids }.compact
137
+ data = app_post("/app/installations/#{Integer(installation_id)}/access_tokens", body)
138
+ data["token"].presence or raise Error, "GitHub returned no installation token"
139
+ rescue Error => e
140
+ raise unavailable_installation(e) || e
141
+ end
142
+
143
+ # Trades the code the manifest flow returns for the new App's settings:
144
+ # id, slug, client_id, client_secret, pem and html_url among them.
145
+ def convert_manifest(code)
146
+ raise Error, "GitHub returned an invalid manifest code" unless code.is_a?(String) && code.match?(/\A[A-Za-z0-9_-]{1,200}\z/)
147
+
148
+ uri = URI.parse("#{API}/app-manifests/#{code}/conversions")
149
+ perform(uri, Net::HTTP::Post.new(uri, api_headers))
150
+ end
151
+
152
+ def perform(uri, request)
153
+ response = Net::HTTP.start(
154
+ uri.host, uri.port,
155
+ use_ssl: true, open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS
156
+ ) { |http| http.request(request) }
157
+
158
+ status = response.code.to_i
159
+ raise Unauthorized.new("GitHub rejected the token", status: status) if status == 401
160
+ raise NotFound.new("GitHub found nothing at #{uri.path}", status: status) if [ 404, 409 ].include?(status)
161
+ unless status.between?(200, 299)
162
+ body = error_body(response)
163
+ error = [ 404, 409 ].include?(status) ? NotFound : Error
164
+ raise error.new(refusal_message(response.code, body), status: status, errors: Array(body["errors"]).grep(Hash))
165
+ end
166
+
167
+ JSON.parse(response.body.presence || "{}")
168
+ rescue JSON::ParserError
169
+ raise Error, "GitHub answered with something other than JSON"
170
+ rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
171
+ raise Error, "GitHub is unreachable (#{e.class.name})"
172
+ end
173
+
174
+ def slice_repository(repo)
175
+ {
176
+ "id" => repo["id"],
177
+ "full_name" => repo["full_name"],
178
+ "private" => repo["private"] == true,
179
+ "default_branch" => repo["default_branch"].presence || "main",
180
+ "description" => repo["description"],
181
+ "html_url" => repo["html_url"],
182
+ "pushed_at" => repo["pushed_at"]
183
+ }
184
+ end
185
+
186
+ def api_headers(authorization = nil)
187
+ {
188
+ "Accept" => "application/vnd.github+json",
189
+ "X-GitHub-Api-Version" => API_VERSION,
190
+ "User-Agent" => USER_AGENT,
191
+ "Authorization" => authorization
192
+ }.compact
193
+ end
194
+
195
+ private
196
+
197
+ def app_post(path, body)
198
+ uri = URI.parse("#{API}#{path}")
199
+ request = Net::HTTP::Post.new(uri, api_headers("Bearer #{app_jwt}").merge("Content-Type" => "application/json"))
200
+ request.body = body.to_json
201
+ perform(uri, request)
202
+ end
203
+
204
+ def app_private_key
205
+ pem = ActionAgent.github_app_private_key
206
+ raise Error, "No GitHub App private key is configured" if pem.blank?
207
+
208
+ key = OpenSSL::PKey.read(pem)
209
+ raise Error, "The GitHub App private key is not an RSA key" unless key.is_a?(OpenSSL::PKey::RSA)
210
+
211
+ key
212
+ rescue OpenSSL::PKey::PKeyError
213
+ raise Error, "The GitHub App private key could not be read"
214
+ end
215
+
216
+ def unavailable_installation(error)
217
+ return nil if error.is_a?(InstallationUnavailable)
218
+
219
+ if error.status == 404
220
+ InstallationUnavailable.new("The GitHub App installation was removed", reason: :removed, status: 404)
221
+ elsif error.status == 403 && error.message.match?(/suspend/i)
222
+ InstallationUnavailable.new("The GitHub App installation is suspended", reason: :suspended, status: 403)
223
+ end
224
+ end
225
+
226
+ # The status, and GitHub's own message when the body carries one, with
227
+ # the messages of a validation failure's errors after it:
228
+ # "GitHub answered 404 (Not Found)", "GitHub answered 422 (Validation
229
+ # Failed: A pull request already exists for acme:fix.)".
230
+ def refusal_message(code, body)
231
+ errors = Array(body["errors"]).filter_map { |error| error["message"] if error.is_a?(Hash) && error["message"].is_a?(String) }
232
+ detail = [ body["message"].is_a?(String) ? body["message"] : nil, errors.join(" ").presence ].compact.join(": ")
233
+ detail = detail.truncate(300).presence
234
+ detail ? "GitHub answered #{code} (#{detail})" : "GitHub answered #{code}"
235
+ end
236
+
237
+ # The JSON object GitHub answered a failed request with, or {}.
238
+ def error_body(response)
239
+ body = JSON.parse(response.body.to_s)
240
+ body.is_a?(Hash) ? body : {}
241
+ rescue JSON::ParserError, TypeError
242
+ {}
243
+ end
244
+
245
+ def base64url(bytes)
246
+ Base64.urlsafe_encode64(bytes, padding: false)
247
+ end
64
248
  end
65
249
 
66
250
  def initialize(access_token)
@@ -75,37 +259,194 @@ module ActionAgent
75
259
  # an organization), most recently pushed first, reduced to the fields the
76
260
  # dashboard stores.
77
261
  def repositories
262
+ paginate do |page|
263
+ get("/user/repos", per_page: PER_PAGE, page: page, sort: "pushed", affiliation: "owner,collaborator,organization_member")
264
+ end
265
+ end
266
+
267
+ # One repository the token reaches, by owner/name, sliced like
268
+ # #repositories; nil when GitHub finds none. Reaches repositories past
269
+ # the listing's cap.
270
+ def repository(full_name)
271
+ self.class.slice_repository(get(repository_path(full_name)))
272
+ rescue NotFound
273
+ nil
274
+ end
275
+
276
+ # The content of +path+ in +full_name+ at +ref+, or nil when there is no
277
+ # file there or it is larger than MAX_FILE_BYTES.
278
+ #
279
+ # @return [String, nil] UTF-8, invalid bytes replaced
280
+ def file(full_name, path, ref: nil)
281
+ encoded = path.to_s.split("/").map { |segment| ERB::Util.url_encode(segment) }.join("/")
282
+ data = get("#{repository_path(full_name)}/contents/#{encoded}", ref.present? ? { ref: ref } : {})
283
+ return nil unless data.is_a?(Hash) && data["type"] == "file" && data["encoding"] == "base64"
284
+ return nil if data["size"].to_i > MAX_FILE_BYTES
285
+
286
+ data["content"].to_s.unpack1("m").force_encoding(Encoding::UTF_8).scrub
287
+ rescue NotFound
288
+ nil
289
+ end
290
+
291
+ # The SHA of the commit +ref+ (a branch, tag or commit; the default
292
+ # branch when nil) names in +full_name+, or nil when there is none.
293
+ def commit_sha(full_name, ref)
294
+ query = { per_page: 1 }
295
+ query[:sha] = ref if ref.present?
296
+ commit = Array(get("#{repository_path(full_name)}/commits", query)).first
297
+ commit.is_a?(Hash) ? commit["sha"].presence : nil
298
+ rescue NotFound
299
+ nil
300
+ end
301
+
302
+ # Every file path in +full_name+ at +ref+, with its size:
303
+ # { paths: [{ path:, size: }], truncated: }. GitHub truncates the
304
+ # listing of a very large repository.
305
+ def tree(full_name, ref:)
306
+ data = get("#{repository_path(full_name)}/git/trees/#{ERB::Util.url_encode(ref.to_s)}", recursive: 1)
307
+ paths = Array(data["tree"]).filter_map do |entry|
308
+ { path: entry["path"].to_s, size: entry["size"].to_i } if entry.is_a?(Hash) && entry["type"] == "blob"
309
+ end
310
+ { paths: paths, truncated: data["truncated"] == true }
311
+ end
312
+
313
+ # The GitHub App's installations a GitHub App user token's user can
314
+ # reach, as GitHub describes them (id, account, repository_selection,
315
+ # permissions, suspended_at).
316
+ def user_installations
78
317
  (1..MAX_PAGES).each_with_object([]) do |page, all|
79
- batch = get("/user/repos", per_page: PER_PAGE, page: page, sort: "pushed",
80
- affiliation: "owner,collaborator,organization_member")
81
- all.concat(Array(batch).map { |repo| self.class.slice_repository(repo) })
82
- break all if Array(batch).size < PER_PAGE
318
+ batch = Array(get("/user/installations", per_page: PER_PAGE, page: page)["installations"])
319
+ all.concat(batch)
320
+ break all if batch.size < PER_PAGE
83
321
  end
84
322
  end
85
323
 
86
- def self.slice_repository(repo)
87
- {
88
- "id" => repo["id"],
89
- "full_name" => repo["full_name"],
90
- "private" => repo["private"] == true,
91
- "default_branch" => repo["default_branch"].presence || "main",
92
- "description" => repo["description"],
93
- "html_url" => repo["html_url"],
94
- "pushed_at" => repo["pushed_at"]
95
- }
324
+ # The user's membership in +organization+ ({ "state" =>, "role" => }), or
325
+ # nil when GitHub reports none.
326
+ def organization_membership(organization)
327
+ raise Error, "#{organization.inspect} is not a GitHub login" unless organization.is_a?(String) && organization.match?(LOGIN)
328
+
329
+ get("/user/memberships/orgs/#{organization}")
330
+ rescue Error => e
331
+ raise unless e.status.in?([ 403, 404 ])
332
+
333
+ nil
334
+ end
335
+
336
+ # The repositories an installation token reaches, reduced like
337
+ # #repositories.
338
+ def installation_repositories
339
+ paginate { |page| Array(get("/installation/repositories", per_page: PER_PAGE, page: page)["repositories"]) }
340
+ end
341
+
342
+ # --- Publishing through the Git Data API --------------------------------
343
+ #
344
+ # +repository+ is "owner/name". A branch is named without refs/heads/.
345
+
346
+ # The commit +branch+ points at, or nil when there is no such branch.
347
+ def branch_head(repository, branch)
348
+ data = get("#{repository_path(repository)}/git/ref/heads/#{branch_path(branch)}")
349
+ data.dig("object", "sha")
350
+ rescue Error => e
351
+ raise unless e.status == 404
352
+
353
+ nil
354
+ end
355
+
356
+ # The tree a commit records.
357
+ def commit_tree(repository, sha)
358
+ get("#{repository_path(repository)}/git/commits/#{object_id!(sha)}").dig("tree", "sha") or
359
+ raise Error, "GitHub returned no tree for #{sha}"
360
+ end
361
+
362
+ # Stores +content+ (bytes) as a blob and returns its sha.
363
+ def create_blob(repository, content)
364
+ created_sha(post("#{repository_path(repository)}/git/blobs", content: Base64.strict_encode64(content.b), encoding: "base64"))
365
+ end
366
+
367
+ # A tree of +entries+ on top of +base_tree+: each { path:, mode:, sha: },
368
+ # where a nil sha deletes the path. Returns its sha.
369
+ def create_tree(repository, base_tree:, entries:)
370
+ tree = entries.map { |entry| { path: entry[:path], mode: entry[:mode], type: "blob", sha: entry[:sha] } }
371
+ created_sha(post("#{repository_path(repository)}/git/trees", base_tree: object_id!(base_tree), tree: tree))
372
+ end
373
+
374
+ # A commit with no author or committer of its own, so GitHub records and
375
+ # signs it as the token's identity. Returns its sha.
376
+ def create_commit(repository, message:, tree:, parents:)
377
+ body = { message: message, tree: object_id!(tree), parents: parents.map { |parent| object_id!(parent) } }
378
+ created_sha(post("#{repository_path(repository)}/git/commits", body))
379
+ end
380
+
381
+ # Creates +branch+ at +sha+. GitHub refuses with 422 when it exists.
382
+ def create_branch(repository, branch, sha)
383
+ post("#{repository_path(repository)}/git/refs", ref: "refs/heads/#{branch}", sha: object_id!(sha))
384
+ end
385
+
386
+ # Moves +branch+ to +sha+, which GitHub accepts only as a fast-forward.
387
+ def fast_forward_branch(repository, branch, sha)
388
+ patch("#{repository_path(repository)}/git/refs/heads/#{branch_path(branch)}", sha: object_id!(sha), force: false)
389
+ end
390
+
391
+ # Opens a pull request of +head+ into +base+, and returns GitHub's
392
+ # description of it (number, html_url, state, draft, merged).
393
+ def create_pull_request(repository, title:, body:, head:, base:, draft:)
394
+ post("#{repository_path(repository)}/pulls", title: title, body: body.to_s, head: head, base: base, draft: draft)
395
+ end
396
+
397
+ def pull_request(repository, number)
398
+ get("#{repository_path(repository)}/pulls/#{Integer(number)}")
96
399
  end
97
400
 
98
401
  private
99
402
 
403
+ def repository_path(repository)
404
+ raise Error, "#{repository.inspect} is not a GitHub repository name" unless repository.is_a?(String) && repository.match?(REPOSITORY)
405
+
406
+ "/repos/#{repository}"
407
+ end
408
+
409
+ def branch_path(branch)
410
+ branch.to_s.split("/").map { |part| ERB::Util.url_encode(part) }.join("/")
411
+ end
412
+
413
+ def object_id!(sha)
414
+ raise Error, "#{sha.inspect} is not a git object id" unless sha.is_a?(String) && sha.match?(OBJECT_ID)
415
+
416
+ sha
417
+ end
418
+
419
+ def created_sha(data)
420
+ data["sha"].presence or raise Error, "GitHub returned no sha"
421
+ end
422
+
423
+ def post(path, body)
424
+ write(Net::HTTP::Post, path, body)
425
+ end
426
+
427
+ def patch(path, body)
428
+ write(Net::HTTP::Patch, path, body)
429
+ end
430
+
431
+ def write(verb, path, body)
432
+ uri = URI.parse("#{API}#{path}")
433
+ request = verb.new(uri, self.class.api_headers("Bearer #{@access_token}").merge("Content-Type" => "application/json"))
434
+ request.body = body.to_json
435
+ self.class.perform(uri, request)
436
+ end
437
+
438
+ def paginate
439
+ (1..MAX_PAGES).each_with_object([]) do |page, all|
440
+ batch = Array(yield(page))
441
+ all.concat(batch.map { |repo| self.class.slice_repository(repo) })
442
+ break all if batch.size < PER_PAGE
443
+ end
444
+ end
445
+
100
446
  def get(path, params = {})
101
447
  uri = URI.parse("#{API}#{path}")
102
448
  uri.query = params.to_query if params.any?
103
- request = Net::HTTP::Get.new(uri,
104
- "Accept" => "application/vnd.github+json",
105
- "Authorization" => "Bearer #{@access_token}",
106
- "X-GitHub-Api-Version" => "2022-11-28",
107
- "User-Agent" => "activeagent-dashboard")
108
- self.class.perform(uri, request)
449
+ self.class.perform(uri, Net::HTTP::Get.new(uri, self.class.api_headers("Bearer #{@access_token}")))
109
450
  end
110
451
  end
111
452
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to tell clients subscribed to an Action Cable stream that a record
5
+ # changed. A message is only { type:, id:, status: }, and a client that
6
+ # wants the record refetches it over the JSON API, which checks who is
7
+ # asking. The engine ships no channel classes, so a stream is only as
8
+ # private as the host channel that serves it, and a message names the
9
+ # record and its status and none of its content.
10
+ #
11
+ # Nothing is sent when the host has not loaded Action Cable; the dashboard
12
+ # polls instead.
13
+ module LiveUpdates
14
+ class << self
15
+ # Sends { type:, id:, status: } on +stream+.
16
+ #
17
+ # @return [Boolean] whether a message was sent
18
+ def broadcast(stream, type:, id:, status:)
19
+ return false unless available?
20
+
21
+ ::ActionCable.server.broadcast(stream, { type: type.to_s, id: id, status: status&.to_s })
22
+ true
23
+ end
24
+
25
+ # Whether the host loaded Action Cable.
26
+ def available?
27
+ defined?(::ActionCable) && ::ActionCable.respond_to?(:server) ? true : false
28
+ end
29
+ end
30
+ end
31
+ end