actionagent 1.8.1 → 1.9.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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
- data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/interaction_feedback.rb +24 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +155 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- 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
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
87
|
+
"#{AUTHORIZE_URL}?#{query}"
|
|
88
|
+
end
|
|
49
89
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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/
|
|
80
|
-
|
|
81
|
-
all
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|