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.
- 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 +71 -12
- 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 +58 -7
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -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 +148 -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 +627 -20
- 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 +39 -10
- 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 +72 -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 +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- 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/model_pricing.rb +214 -34
- 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 +207 -4
- 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 +300 -16
- 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/evaluation_serializer.rb +41 -10
- 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_scorecard.rb +50 -21
- 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/codex_session_events.rb +37 -0
- 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_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- 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 +1263 -102
- 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 +290 -23
- 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 +121 -17
- 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 +153 -2
- 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 +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -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/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
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module ActionAgent
|
|
6
|
+
# Used to open a pull request from a checkout sandbox's changes, through
|
|
7
|
+
# GitHub's Git Data API from the dashboard's own process.
|
|
8
|
+
#
|
|
9
|
+
# No git process ever holds the token it writes with. The sandbox backend
|
|
10
|
+
# only lists and reads files (SandboxOrchestrator#changed_files and
|
|
11
|
+
# #read_file), and the token is minted when the publish runs, limited to
|
|
12
|
+
# the one repository: an installation token with contents and pull request
|
|
13
|
+
# write, or the OAuth connection's token when its own user publishes and
|
|
14
|
+
# its scopes allow writing.
|
|
15
|
+
#
|
|
16
|
+
# A preview, a publish and a patch all read the changes the same way:
|
|
17
|
+
#
|
|
18
|
+
# 1. list: the backend's changed files since the checkout commit
|
|
19
|
+
# 2. filter: a path outside the caller's allowlist or under .github/, a
|
|
20
|
+
# symlink, a submodule, or a file over MAX_FILE_BYTES is refused
|
|
21
|
+
# 3. read: each remaining file's content now and in that commit, up to
|
|
22
|
+
# MAX_FILES files and READ_BUDGET_BYTES; a file past either is not
|
|
23
|
+
# read, and cannot be chosen until fewer files are asked for
|
|
24
|
+
# 4. scan: a file holding one of the sandbox's secrets, or anything shaped
|
|
25
|
+
# like a GitHub token, is refused
|
|
26
|
+
#
|
|
27
|
+
# A refused file is named with the reason and never published. A publish
|
|
28
|
+
# or a patch reads only the files it was asked for. A publish then writes
|
|
29
|
+
# blobs, a tree on the checkout commit's tree, a commit, and either a
|
|
30
|
+
# branch that did not exist and a draft pull request, or a fast-forward of
|
|
31
|
+
# the pull request's branch (see DraftPullRequest for its operations).
|
|
32
|
+
#
|
|
33
|
+
# The branch of a pull request always holds the checkout commit's tree with
|
|
34
|
+
# the files of its last publish on top, so its diff on GitHub is the diff
|
|
35
|
+
# the dialog showed. An update that leaves out a file published earlier
|
|
36
|
+
# returns that file to its content in the checkout commit.
|
|
37
|
+
#
|
|
38
|
+
# A publisher can also be given generated files: content the dashboard
|
|
39
|
+
# writes itself (a project's sandbox.yml, its evaluation suite) and offers
|
|
40
|
+
# beside the sandbox's own changes, compared with the checkout commit and
|
|
41
|
+
# filtered and scanned the same way.
|
|
42
|
+
class DraftPullRequestPublisher
|
|
43
|
+
# A publish, preview or patch that cannot go ahead. +code+ is a word the
|
|
44
|
+
# dashboard acts on:
|
|
45
|
+
#
|
|
46
|
+
# unsupported the backend or sandbox cannot be read
|
|
47
|
+
# not_live the sandbox is not ready or running
|
|
48
|
+
# unreadable the backend failed to read the changes
|
|
49
|
+
# nothing_selected no file was chosen
|
|
50
|
+
# not_publishable a chosen file is refused (see FILE_REFUSALS)
|
|
51
|
+
# secret a chosen file holds a secret
|
|
52
|
+
# changed_since_preview a chosen file differs from its preview
|
|
53
|
+
# too_large the chosen files exceed MAX_FILES or
|
|
54
|
+
# MAX_TOTAL_BYTES, or the changes asked for
|
|
55
|
+
# exceed what one read covers
|
|
56
|
+
# invalid_branch the branch name is not one git allows
|
|
57
|
+
# branch_exists the branch already exists on GitHub
|
|
58
|
+
# branch_moved the branch to update has commits the dashboard
|
|
59
|
+
# did not publish, or is gone
|
|
60
|
+
# no_credential nothing can write to the repository
|
|
61
|
+
# write_refused GitHub refused a write (403, 404) or the token
|
|
62
|
+
# github_error GitHub failed otherwise
|
|
63
|
+
class Refused < StandardError
|
|
64
|
+
attr_reader :code
|
|
65
|
+
|
|
66
|
+
def initialize(message, code:)
|
|
67
|
+
super(message)
|
|
68
|
+
@code = code
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
MAX_FILE_BYTES = 1024 * 1024
|
|
73
|
+
MAX_TOTAL_BYTES = 10 * 1024 * 1024
|
|
74
|
+
MAX_FILES = 300
|
|
75
|
+
# What one preview, publish or patch reads, counting each file's content
|
|
76
|
+
# now and in the checkout commit.
|
|
77
|
+
READ_BUDGET_BYTES = MAX_TOTAL_BYTES * 2
|
|
78
|
+
MAX_BRANCH_LENGTH = 200
|
|
79
|
+
WRITE_PERMISSIONS = { contents: "write", pull_requests: "write" }.freeze
|
|
80
|
+
STATUS_PERMISSIONS = { pull_requests: "read" }.freeze
|
|
81
|
+
PUBLISHABLE_MODES = %w[100644 100755].freeze
|
|
82
|
+
# Why a changed file is refused, as the dialog words it.
|
|
83
|
+
FILE_REFUSALS = {
|
|
84
|
+
"credential_directory" => "Claude configuration and credentials are never published",
|
|
85
|
+
"excluded" => "files under .github/ are never published",
|
|
86
|
+
"not_allowed" => "not in the files chosen to publish",
|
|
87
|
+
"symlink" => "symlinks are not published",
|
|
88
|
+
"submodule" => "submodules are not published",
|
|
89
|
+
"unsupported_mode" => "only regular files are published",
|
|
90
|
+
"too_large" => "larger than #{MAX_FILE_BYTES / 1024 / 1024} MB",
|
|
91
|
+
"unreadable" => "could not be read",
|
|
92
|
+
"secret" => "contains a secret of this sandbox or a GitHub token",
|
|
93
|
+
"local_path_gem" => "points a gem at a path outside the repository (a local engine checkout, say); " \
|
|
94
|
+
"use a released version or a git source",
|
|
95
|
+
"not_read" => "not read: a preview reads at most #{MAX_FILES} files and #{READ_BUDGET_BYTES / 1024 / 1024} MB, " \
|
|
96
|
+
"so limit the paths to read it"
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
# A changed file, its content now and in the checkout commit, and why it
|
|
100
|
+
# is refused (a FILE_REFUSALS key), or nil when it may be published.
|
|
101
|
+
ChangedFile = Struct.new(:path, :status, :mode, :base_mode, :size, :content, :base_content, :refusal, :generated,
|
|
102
|
+
keyword_init: true) do
|
|
103
|
+
def publishable? = refusal.nil?
|
|
104
|
+
def deleted? = status == "deleted"
|
|
105
|
+
|
|
106
|
+
# What the dialog previewed of this file. A publish is refused when
|
|
107
|
+
# the file no longer matches it.
|
|
108
|
+
def digest
|
|
109
|
+
Digest::SHA256.hexdigest("#{status}\0#{mode}\0".b + content.to_s.b)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# This file as SandboxPatch takes a change.
|
|
113
|
+
def to_change
|
|
114
|
+
{ path: path, status: status, mode: mode, base_mode: base_mode, content: content, base_content: base_content }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The changes of a sandbox: the commit it checked out, and its files.
|
|
119
|
+
Changes = Struct.new(:base_commit, :files, keyword_init: true) do
|
|
120
|
+
def publishable = files.select(&:publishable?)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# How a publish writes to the repository: through an installation
|
|
124
|
+
# ("app") or the OAuth connection ("oauth"); or +refusal+, why neither
|
|
125
|
+
# can.
|
|
126
|
+
Credential = Struct.new(:kind, :installation, :connection, :repository_id, :refusal, keyword_init: true) do
|
|
127
|
+
def available? = refusal.nil?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class << self
|
|
131
|
+
# Why +name+ cannot be a branch, or nil: the rules of
|
|
132
|
+
# git-check-ref-format, and at most MAX_BRANCH_LENGTH characters.
|
|
133
|
+
def branch_refusal(name)
|
|
134
|
+
return "Name the branch" unless name.is_a?(String) && name.present?
|
|
135
|
+
return "A branch name is at most #{MAX_BRANCH_LENGTH} characters" if name.length > MAX_BRANCH_LENGTH
|
|
136
|
+
|
|
137
|
+
valid = name.ascii_only? &&
|
|
138
|
+
!name.match?(%r{[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//|\A[/-]|/\z|\.\z|\A@\z}) &&
|
|
139
|
+
name.split("/").none? { |part| part.start_with?(".") || part.end_with?(".lock") }
|
|
140
|
+
valid ? nil : "#{name.inspect} is not a valid branch name"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Reads +record+'s pull request again when it was last read at least
|
|
144
|
+
# DraftPullRequest::STATUS_REFRESH_INTERVAL ago, with a token that can
|
|
145
|
+
# read pull requests and nothing else. A failure keeps what was known.
|
|
146
|
+
#
|
|
147
|
+
# @return [DraftPullRequest] +record+
|
|
148
|
+
def refresh_status!(record)
|
|
149
|
+
return record unless record.claim_status_refresh!
|
|
150
|
+
|
|
151
|
+
client = status_client(record)
|
|
152
|
+
return record if client.nil?
|
|
153
|
+
|
|
154
|
+
pull = client.pull_request(record.repository, record.number)
|
|
155
|
+
record.update_columns(state: pull_state(pull), draft: pull["draft"] == true)
|
|
156
|
+
record
|
|
157
|
+
rescue GithubClient::Error => e
|
|
158
|
+
Rails.logger.warn("[ActionAgent] could not refresh pull request #{record.repository}##{record.number}: #{e.message}")
|
|
159
|
+
record
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def pull_state(pull)
|
|
163
|
+
pull["merged"] == true || pull["merged_at"].present? ? "merged" : pull["state"].to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def status_client(record)
|
|
169
|
+
sandbox = record.sandbox_session
|
|
170
|
+
if record.credential_kind == "app"
|
|
171
|
+
installation = sandbox&.checkout_installation
|
|
172
|
+
repo = installation&.repository(record.repository)
|
|
173
|
+
return nil unless ActionAgent.github_app_configured? && repo && installation.usable?
|
|
174
|
+
|
|
175
|
+
GithubClient.new(installation.mint_token!(permissions: STATUS_PERMISSIONS, repository_ids: [ repo["id"] ]))
|
|
176
|
+
else
|
|
177
|
+
sandbox&.github_connection&.client
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
attr_reader :sandbox
|
|
183
|
+
|
|
184
|
+
# +user+ is who publishes: the OAuth connection writes only for the user
|
|
185
|
+
# who connected it. +generated_files+ maps a checkout path to the content
|
|
186
|
+
# the dashboard offers for it, in place of anything the sandbox wrote
|
|
187
|
+
# there.
|
|
188
|
+
def initialize(sandbox, user: nil, orchestrator: nil, generated_files: {})
|
|
189
|
+
@sandbox = sandbox
|
|
190
|
+
@user = user
|
|
191
|
+
@orchestrator = orchestrator || SandboxOrchestrator.new
|
|
192
|
+
@generated_files = generated_files.to_h.transform_keys(&:to_s)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def repository
|
|
196
|
+
sandbox.repository
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Why the sandbox's changes cannot be read now, or nil.
|
|
200
|
+
#
|
|
201
|
+
# @return [Refused, nil]
|
|
202
|
+
def read_refusal
|
|
203
|
+
return Refused.new("Pull requests are opened from checkout sandboxes only", code: "unsupported") unless sandbox.app_runtime?
|
|
204
|
+
unless @orchestrator.supports?(:changed_files) && @orchestrator.supports?(:read_file)
|
|
205
|
+
return Refused.new("The #{@orchestrator.backend_name} sandbox backend cannot read a sandbox's files", code: "unsupported")
|
|
206
|
+
end
|
|
207
|
+
unless @orchestrator.reads_checkouts?
|
|
208
|
+
return Refused.new("The #{@orchestrator.backend_name} sandbox backend cannot read the commit a checkout was cloned at",
|
|
209
|
+
code: "unsupported")
|
|
210
|
+
end
|
|
211
|
+
return nil if (sandbox.ready? || sandbox.running?) && sandbox.active?
|
|
212
|
+
|
|
213
|
+
state = sandbox.ready? || sandbox.running? ? "expired" : sandbox.status
|
|
214
|
+
Refused.new("The sandbox is #{state}. Publishing reads its live checkout, so start a new sandbox to publish", code: "not_live")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The files the sandbox changed, and the generated files that differ
|
|
218
|
+
# from the checkout commit, each filtered, read and scanned. +allowlist+
|
|
219
|
+
# holds the patterns a path must match to be published (File.fnmatch
|
|
220
|
+
# patterns, where "dir/**" matches everything under dir, or a Regexp the
|
|
221
|
+
# whole path must match); nil allows every path. +paths+ names the only
|
|
222
|
+
# files to list and read; nil lists every changed file.
|
|
223
|
+
#
|
|
224
|
+
# Files are read in path order until MAX_FILES are read or
|
|
225
|
+
# READ_BUDGET_BYTES is spent, and those after are refused as not_read.
|
|
226
|
+
#
|
|
227
|
+
# @raise [Refused]
|
|
228
|
+
# @return [Changes]
|
|
229
|
+
def changes(allowlist: nil, paths: nil)
|
|
230
|
+
refusal = read_refusal
|
|
231
|
+
raise refusal if refusal
|
|
232
|
+
|
|
233
|
+
listing = @orchestrator.changed_files(sandbox)
|
|
234
|
+
base_commit = listing[:base_commit]
|
|
235
|
+
raise Refused.new("The sandbox did not record the commit it checked out", code: "unsupported") unless commit_id?(base_commit)
|
|
236
|
+
|
|
237
|
+
wanted = paths&.to_set
|
|
238
|
+
files = Array(listing[:files]).filter_map do |entry|
|
|
239
|
+
next if wanted && !wanted.include?(entry[:path].to_s)
|
|
240
|
+
next if @generated_files.key?(entry[:path].to_s)
|
|
241
|
+
|
|
242
|
+
file = ChangedFile.new(
|
|
243
|
+
path: entry[:path].to_s, status: entry[:status].to_s, mode: entry[:mode], base_mode: entry[:base_mode], size: entry[:size]
|
|
244
|
+
)
|
|
245
|
+
file.refusal = path_refusal(file.path, allowlist) || kind_refusal(file)
|
|
246
|
+
file
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
files += @generated_files.filter_map do |path, content|
|
|
250
|
+
next if wanted && !wanted.include?(path)
|
|
251
|
+
|
|
252
|
+
file = ChangedFile.new(path: path, mode: "100644", content: content.to_s.b, size: content.to_s.bytesize, generated: true)
|
|
253
|
+
file.refusal = path_refusal(path, allowlist)
|
|
254
|
+
file
|
|
255
|
+
end
|
|
256
|
+
files.sort_by!(&:path)
|
|
257
|
+
|
|
258
|
+
secrets = scan_values
|
|
259
|
+
budget = READ_BUDGET_BYTES
|
|
260
|
+
read = 0
|
|
261
|
+
files = files.filter_map do |file|
|
|
262
|
+
next file unless file.publishable?
|
|
263
|
+
|
|
264
|
+
if read >= MAX_FILES || budget.negative?
|
|
265
|
+
file.refusal = "not_read"
|
|
266
|
+
next file
|
|
267
|
+
end
|
|
268
|
+
read += 1
|
|
269
|
+
read_contents(file, secrets)&.tap { budget -= file.content.to_s.bytesize + file.base_content.to_s.bytesize }
|
|
270
|
+
end
|
|
271
|
+
Changes.new(base_commit: base_commit, files: files)
|
|
272
|
+
rescue Refused
|
|
273
|
+
raise
|
|
274
|
+
rescue StandardError => e
|
|
275
|
+
Rails.logger.warn("[ActionAgent] could not read the changes of sandbox #{sandbox.session_id}: #{e.class}: #{e.message}")
|
|
276
|
+
raise Refused.new("The sandbox's changes could not be read: #{e.message}", code: "unreadable")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The paths of the files the sandbox changed since the checkout commit,
|
|
280
|
+
# listed by the backend and not read: what #changes would consider
|
|
281
|
+
# before the generated files are added.
|
|
282
|
+
#
|
|
283
|
+
# @raise [Refused]
|
|
284
|
+
# @return [Array<String>]
|
|
285
|
+
def changed_paths
|
|
286
|
+
refusal = read_refusal
|
|
287
|
+
raise refusal if refusal
|
|
288
|
+
|
|
289
|
+
Array(@orchestrator.changed_files(sandbox)[:files]).map { |entry| entry[:path].to_s }
|
|
290
|
+
rescue Refused
|
|
291
|
+
raise
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
Rails.logger.warn("[ActionAgent] could not list the changes of sandbox #{sandbox.session_id}: #{e.class}: #{e.message}")
|
|
294
|
+
raise Refused.new("The sandbox's changes could not be read: #{e.message}", code: "unreadable")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# What the dialog shows before a publish: every changed file with its
|
|
298
|
+
# refusal, and for each one that may be published its diff and digest.
|
|
299
|
+
def preview(allowlist: nil)
|
|
300
|
+
changes = self.changes(allowlist: allowlist)
|
|
301
|
+
files = changes.files.map do |file|
|
|
302
|
+
entry = {
|
|
303
|
+
path: file.path, status: file.status, mode: file.mode, base_mode: file.base_mode,
|
|
304
|
+
size: file.content&.bytesize || file.size,
|
|
305
|
+
refusal: file.refusal, refusal_message: file.refusal && FILE_REFUSALS[file.refusal]
|
|
306
|
+
}
|
|
307
|
+
next entry unless file.publishable?
|
|
308
|
+
|
|
309
|
+
binary = SandboxPatch.binary?(file.content) || SandboxPatch.binary?(file.base_content)
|
|
310
|
+
entry.merge(
|
|
311
|
+
binary: binary,
|
|
312
|
+
digest: file.digest,
|
|
313
|
+
diff: binary ? nil : SandboxPatch.file_diff(file.to_change).force_encoding(Encoding::UTF_8).scrub
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
{
|
|
318
|
+
repository: repository,
|
|
319
|
+
base_commit: changes.base_commit,
|
|
320
|
+
files: files,
|
|
321
|
+
suggested_branch: "activeagent/sandbox-#{sandbox.session_id.to_s.first(8)}",
|
|
322
|
+
limits: { max_file_bytes: MAX_FILE_BYTES, max_total_bytes: MAX_TOTAL_BYTES, max_files: MAX_FILES }
|
|
323
|
+
}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# The files of +changes+ that +selection+ ({ path => digest }, as the
|
|
327
|
+
# preview reported them) chose.
|
|
328
|
+
#
|
|
329
|
+
# @raise [Refused] when nothing is chosen, a chosen file is refused or
|
|
330
|
+
# changed since the preview, or the files exceed MAX_FILES or
|
|
331
|
+
# MAX_TOTAL_BYTES
|
|
332
|
+
# @return [Array<ChangedFile>]
|
|
333
|
+
def select!(changes, selection)
|
|
334
|
+
raise Refused.new("Choose at least one file to publish", code: "nothing_selected") if selection.blank?
|
|
335
|
+
if selection.size > MAX_FILES
|
|
336
|
+
raise Refused.new("#{selection.size} files are chosen; at most #{MAX_FILES} can be published at once", code: "too_large")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
by_path = changes.files.index_by(&:path)
|
|
340
|
+
files = selection.map do |path, digest|
|
|
341
|
+
file = by_path[path]
|
|
342
|
+
raise Refused.new("#{path} has no changes any more. Reload the preview", code: "changed_since_preview") if file.nil?
|
|
343
|
+
|
|
344
|
+
if file.refusal == "not_read"
|
|
345
|
+
raise Refused.new("The chosen files are more than one publish reads (#{MAX_FILES} files, " \
|
|
346
|
+
"#{READ_BUDGET_BYTES / 1024 / 1024} MB). Choose fewer", code: "too_large")
|
|
347
|
+
end
|
|
348
|
+
unless file.publishable?
|
|
349
|
+
code = file.refusal == "secret" ? "secret" : "not_publishable"
|
|
350
|
+
raise Refused.new("#{path} cannot be published: #{FILE_REFUSALS[file.refusal]}", code: code)
|
|
351
|
+
end
|
|
352
|
+
raise Refused.new("#{path} changed since the preview. Reload it", code: "changed_since_preview") unless digest == file.digest
|
|
353
|
+
|
|
354
|
+
file
|
|
355
|
+
end
|
|
356
|
+
total = files.sum { |file| file.content.to_s.bytesize }
|
|
357
|
+
if total > MAX_TOTAL_BYTES
|
|
358
|
+
raise Refused.new("The chosen files hold #{total} bytes; at most #{MAX_TOTAL_BYTES} can be published at once", code: "too_large")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
files
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# A patch of the publishable files named by +paths+ (all of them when
|
|
365
|
+
# nil), for `git am` or `git apply`. Built from the backend's reads,
|
|
366
|
+
# with no GitHub token. +allowlist+ is #changes'.
|
|
367
|
+
#
|
|
368
|
+
# @raise [Refused] when a named file is refused or unchanged, or, without
|
|
369
|
+
# +paths+, when the changes are more than one read covers
|
|
370
|
+
# @return [String] binary-encoded
|
|
371
|
+
def patch(paths: nil, title: nil, body: nil, allowlist: nil)
|
|
372
|
+
changes = self.changes(paths: paths, allowlist: allowlist)
|
|
373
|
+
files = if paths.nil?
|
|
374
|
+
if changes.files.any? { |file| file.refusal == "not_read" }
|
|
375
|
+
raise Refused.new("The sandbox changed more files than one patch reads (#{MAX_FILES} files, " \
|
|
376
|
+
"#{READ_BUDGET_BYTES / 1024 / 1024} MB). Choose the files to download", code: "too_large")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
changes.publishable
|
|
380
|
+
else
|
|
381
|
+
unknown = paths - changes.files.map(&:path)
|
|
382
|
+
raise Refused.new("#{unknown.first} has no changes in this sandbox", code: "changed_since_preview") if unknown.any?
|
|
383
|
+
|
|
384
|
+
select!(changes, changes.files.select { |file| paths.include?(file.path) }.to_h { |file| [ file.path, file.digest ] })
|
|
385
|
+
end
|
|
386
|
+
raise Refused.new("The sandbox has no changes that can be published", code: "nothing_selected") if files.empty?
|
|
387
|
+
|
|
388
|
+
SandboxPatch.format_patch(files.map(&:to_change), subject: title.presence || "Changes from a sandbox of #{repository}",
|
|
389
|
+
body: body, base_commit: changes.base_commit)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# How a publish by the user would write to the repository: the
|
|
393
|
+
# installation the sandbox checked out through, when the App is
|
|
394
|
+
# configured and the installation can write; otherwise the OAuth
|
|
395
|
+
# connection, when the user is the one who connected it and its scopes
|
|
396
|
+
# allow writing to the repository.
|
|
397
|
+
#
|
|
398
|
+
# @return [Credential]
|
|
399
|
+
def credential
|
|
400
|
+
installation = writing_installation
|
|
401
|
+
if installation
|
|
402
|
+
return Credential.new(kind: "app", installation: installation, repository_id: installation.repository(repository)["id"])
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
connection = sandbox.github_connection
|
|
406
|
+
if connection.nil? || connection.repository(repository).nil?
|
|
407
|
+
return Credential.new(refusal: "No GitHub App installation or OAuth connection of this workspace can write to #{repository}")
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
refusal = oauth_refusal(connection)
|
|
411
|
+
refusal ? Credential.new(refusal: refusal) : Credential.new(kind: "oauth", connection: connection)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Runs +record+'s publish and records on it how it went: published,
|
|
415
|
+
# draft_refused, or failed with an error code (see Refused). With
|
|
416
|
+
# +allow_non_draft+, a pull request GitHub refuses to open as a draft is
|
|
417
|
+
# opened as a regular one.
|
|
418
|
+
#
|
|
419
|
+
# @return [DraftPullRequest] +record+
|
|
420
|
+
def publish!(record, allow_non_draft: false)
|
|
421
|
+
record.update!(status: "publishing", error_code: nil, error_message: nil)
|
|
422
|
+
refusal = read_refusal
|
|
423
|
+
raise refusal if refusal
|
|
424
|
+
|
|
425
|
+
credential = self.credential
|
|
426
|
+
raise Refused.new(credential.refusal, code: "no_credential") unless credential.available?
|
|
427
|
+
|
|
428
|
+
client = writing_client(credential)
|
|
429
|
+
record.update!(credential_kind: credential.kind, base_branch: record.base_branch || base_branch(client))
|
|
430
|
+
if record.operation.in?(%w[open_draft open_regular])
|
|
431
|
+
open_pull_request!(record, client, draft: record.operation == "open_draft", allow_non_draft: allow_non_draft)
|
|
432
|
+
else
|
|
433
|
+
push!(record, client)
|
|
434
|
+
record.operation == "create" ? open_pull_request!(record, client, draft: true, allow_non_draft: allow_non_draft) : record.update!(status: "published")
|
|
435
|
+
end
|
|
436
|
+
record
|
|
437
|
+
rescue Refused => e
|
|
438
|
+
record.update!(status: "failed", error_code: e.code, error_message: e.message)
|
|
439
|
+
record
|
|
440
|
+
rescue GithubClient::InstallationUnavailable => e
|
|
441
|
+
record.update!(status: "failed", error_code: "no_credential",
|
|
442
|
+
error_message: "#{e.message}: reinstall the GitHub App, or Check again in Settings -> Integrations")
|
|
443
|
+
record
|
|
444
|
+
rescue GithubClient::Unauthorized
|
|
445
|
+
record.update!(status: "failed", error_code: "no_credential",
|
|
446
|
+
error_message: "GitHub rejected the token. Reconnect GitHub in Settings -> Integrations")
|
|
447
|
+
record
|
|
448
|
+
rescue GithubClient::Error => e
|
|
449
|
+
code = e.status.in?([ 403, 404 ]) ? "write_refused" : "github_error"
|
|
450
|
+
record.update!(status: "failed", error_code: code, error_message: "GitHub refused the publish: #{e.message}")
|
|
451
|
+
record
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
private
|
|
455
|
+
|
|
456
|
+
# +file+ with its contents read and scanned, or nil when it no longer
|
|
457
|
+
# differs from the checkout commit. A generated file's content is its
|
|
458
|
+
# own, and its status follows from the checkout commit's.
|
|
459
|
+
def read_contents(file, secrets)
|
|
460
|
+
return file.tap { file.refusal = "too_large" } if file.size.to_i > MAX_FILE_BYTES
|
|
461
|
+
|
|
462
|
+
begin
|
|
463
|
+
if file.generated
|
|
464
|
+
file.base_content = @orchestrator.read_file(sandbox, file.path, base: true)
|
|
465
|
+
file.status = file.base_content.nil? ? "added" : "modified"
|
|
466
|
+
file.base_mode = file.base_content.nil? ? nil : "100644"
|
|
467
|
+
else
|
|
468
|
+
file.content = @orchestrator.read_file(sandbox, file.path) unless file.deleted?
|
|
469
|
+
file.base_content = @orchestrator.read_file(sandbox, file.path, base: true) unless file.status == "added"
|
|
470
|
+
end
|
|
471
|
+
rescue StandardError => e
|
|
472
|
+
Rails.logger.info("[ActionAgent] could not read #{file.path} from sandbox #{sandbox.session_id}: #{e.message}")
|
|
473
|
+
return file.tap { file.refusal = "unreadable" }
|
|
474
|
+
end
|
|
475
|
+
# Gone or reverted since it was listed.
|
|
476
|
+
return nil if file.deleted? ? file.base_content.nil? : file.content.nil?
|
|
477
|
+
return nil if file.status == "modified" && file.content == file.base_content && file.mode == (file.base_mode || file.mode)
|
|
478
|
+
|
|
479
|
+
if [ file.content, file.base_content ].any? { |content| content.to_s.bytesize > MAX_FILE_BYTES }
|
|
480
|
+
file.refusal = "too_large"
|
|
481
|
+
elsif secret?(file.content, secrets)
|
|
482
|
+
file.refusal = "secret"
|
|
483
|
+
elsif local_path_gem?(file)
|
|
484
|
+
file.refusal = "local_path_gem"
|
|
485
|
+
end
|
|
486
|
+
file
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def path_refusal(path, allowlist)
|
|
490
|
+
return "credential_directory" if SandboxCredentialPaths.protected?(path)
|
|
491
|
+
return "excluded" if path.split("/").first.to_s.casecmp?(".github")
|
|
492
|
+
return nil if allowlist.nil?
|
|
493
|
+
|
|
494
|
+
flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
495
|
+
allowed = allowlist.any? do |pattern|
|
|
496
|
+
pattern.is_a?(Regexp) ? pattern.match?(path) : File.fnmatch?(glob(pattern), path, flags)
|
|
497
|
+
end
|
|
498
|
+
allowed ? nil : "not_allowed"
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# +pattern+ as File.fnmatch reads it, with a trailing "/**" matching
|
|
502
|
+
# everything under the directory: "app/**" -> "app/**/*".
|
|
503
|
+
def glob(pattern)
|
|
504
|
+
pattern.end_with?("/**") ? "#{pattern}/*" : pattern
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def kind_refusal(file)
|
|
508
|
+
modes = [ file.mode, file.base_mode ].compact
|
|
509
|
+
return "symlink" if modes.include?("120000")
|
|
510
|
+
return "submodule" if modes.include?("160000")
|
|
511
|
+
return "unsupported_mode" unless file.deleted? || PUBLISHABLE_MODES.include?(file.mode)
|
|
512
|
+
|
|
513
|
+
nil
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# The values a published file must not contain: everything the
|
|
517
|
+
# sandbox's own output is scrubbed of, and the OAuth connection's token.
|
|
518
|
+
def scan_values
|
|
519
|
+
values = sandbox.secret_values
|
|
520
|
+
values << sandbox.github_connection&.access_token
|
|
521
|
+
values.compact.map(&:to_s).select { |value| value.length >= SecretScrubber::MIN_SECRET_LENGTH }.map(&:b).uniq
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Bundler files a sandbox may rewrite with gems from its own machine: the
|
|
525
|
+
# :local backend's add_engine step bundles a dashboard that runs its
|
|
526
|
+
# engine from a path with `bundle add --path`, and that path means
|
|
527
|
+
# nothing in the repository.
|
|
528
|
+
BUNDLER_FILES = %w[Gemfile Gemfile.lock gems.rb gems.locked].freeze
|
|
529
|
+
# A path: option, or a lockfile PATH remote, that is absolute, in a home
|
|
530
|
+
# directory or climbs out of the checkout.
|
|
531
|
+
OUTSIDE_PATH = %r{(?:\bpath:\s*|\bpath\(\s*|^\s+remote:\s+)["']?(?:/|~|\.\./)[^"'\s,)]*}
|
|
532
|
+
|
|
533
|
+
# Whether +file+ is a Bundler file that newly points a gem outside the
|
|
534
|
+
# checkout. One the repository already had is left alone.
|
|
535
|
+
def local_path_gem?(file)
|
|
536
|
+
return false unless BUNDLER_FILES.include?(File.basename(file.path)) && file.content
|
|
537
|
+
|
|
538
|
+
lockfile = file.path.end_with?(".lock", ".locked")
|
|
539
|
+
refs = ->(content) { path_references(content.to_s.b, lockfile) }
|
|
540
|
+
(refs.call(file.content) - refs.call(file.base_content)).any?
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def path_references(content, lockfile)
|
|
544
|
+
if lockfile
|
|
545
|
+
content.scan(/^PATH\n((?: .*\n?)*)/).flatten.flat_map { |block| block.scan(OUTSIDE_PATH) }
|
|
546
|
+
else
|
|
547
|
+
content.lines.reject { |line| line.lstrip.start_with?("#") }.flat_map { |line| line.scan(OUTSIDE_PATH) }
|
|
548
|
+
end.map(&:strip).uniq
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def secret?(content, secrets)
|
|
552
|
+
return false if content.nil?
|
|
553
|
+
|
|
554
|
+
content = content.b
|
|
555
|
+
secrets.any? { |secret| content.include?(secret) } || content.match?(SecretScrubber::GITHUB_TOKEN)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def writing_installation
|
|
559
|
+
return nil unless ActionAgent.github_app_configured?
|
|
560
|
+
|
|
561
|
+
installation = sandbox.checkout_installation
|
|
562
|
+
return nil unless installation&.usable? && installation.repository(repository)
|
|
563
|
+
|
|
564
|
+
granted = installation.permissions
|
|
565
|
+
return installation if granted.empty?
|
|
566
|
+
|
|
567
|
+
WRITE_PERMISSIONS.all? { |name, level| granted[name.to_s] == level } ? installation : nil
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Why the OAuth connection cannot publish for the user, or nil. Where
|
|
571
|
+
# the host has a user model, only the user who connected it may; a
|
|
572
|
+
# connection made before the dashboard recorded that user must be
|
|
573
|
+
# connected again.
|
|
574
|
+
def oauth_refusal(connection)
|
|
575
|
+
if ActionAgent.user_class.present?
|
|
576
|
+
if connection.user_id.nil?
|
|
577
|
+
return "The OAuth connection does not record who connected it. Reconnect GitHub in Settings -> Integrations to publish with it"
|
|
578
|
+
end
|
|
579
|
+
unless @user.respond_to?(:id) && @user.id.to_s == connection.user_id.to_s
|
|
580
|
+
return "Only the user who connected GitHub (@#{connection.login}) can publish through the OAuth connection"
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
scopes = connection.scopes.to_s.split(/[\s,]+/).reject(&:blank?)
|
|
585
|
+
return nil if scopes.include?("repo")
|
|
586
|
+
return nil if scopes.include?("public_repo") && connection.repository(repository)["private"] == false
|
|
587
|
+
|
|
588
|
+
"The OAuth connection's scopes (#{scopes.join(', ').presence || 'none'}) do not allow writing to #{repository}. " \
|
|
589
|
+
"Reconnect GitHub with the repo scope"
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def writing_client(credential)
|
|
593
|
+
return credential.connection.client if credential.kind == "oauth"
|
|
594
|
+
|
|
595
|
+
GithubClient.new(credential.installation.mint_token!(permissions: WRITE_PERMISSIONS, repository_ids: [ credential.repository_id ]))
|
|
596
|
+
rescue GithubClient::InstallationUnavailable
|
|
597
|
+
raise
|
|
598
|
+
rescue GithubClient::Error => e
|
|
599
|
+
raise unless e.status == 422
|
|
600
|
+
|
|
601
|
+
raise Refused.new("The GitHub App installation cannot write to #{repository}: #{e.message}", code: "write_refused")
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# The branch the sandbox checked out, or the repository's default branch
|
|
605
|
+
# when it checked out a tag or a commit.
|
|
606
|
+
def base_branch(client)
|
|
607
|
+
ref = sandbox.repository_ref
|
|
608
|
+
return ref if ref.present? && client.branch_head(repository, ref)
|
|
609
|
+
|
|
610
|
+
repo = sandbox.checkout_installation&.repository(repository) || sandbox.github_connection&.repository(repository)
|
|
611
|
+
repo&.dig("default_branch").presence || "main"
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Writes the chosen files as a commit and points the branch at it: a new
|
|
615
|
+
# branch for a create, a fast-forward for an update.
|
|
616
|
+
def push!(record, client)
|
|
617
|
+
changes = self.changes(paths: record.files.map { |file| file["path"] })
|
|
618
|
+
unless changes.base_commit == record.base_commit
|
|
619
|
+
raise Refused.new("The sandbox's checkout commit is not the one previewed. Reload the preview", code: "changed_since_preview")
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
files = select!(changes, record.files.to_h { |file| [ file["path"], file["digest"] ] })
|
|
623
|
+
parent = expected_parent!(record, client)
|
|
624
|
+
base_tree = begin
|
|
625
|
+
client.commit_tree(record.repository, record.base_commit)
|
|
626
|
+
rescue GithubClient::Error => e
|
|
627
|
+
raise unless e.status.in?([ 404, 422 ])
|
|
628
|
+
|
|
629
|
+
raise Refused.new("GitHub has no commit #{record.base_commit}, which the sandbox checked out", code: "github_error")
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
entries = files.map do |file|
|
|
633
|
+
sha = file.deleted? ? nil : client.create_blob(record.repository, file.content)
|
|
634
|
+
{ path: file.path, mode: file.mode || "100644", sha: sha }
|
|
635
|
+
end
|
|
636
|
+
tree = client.create_tree(record.repository, base_tree: base_tree, entries: entries)
|
|
637
|
+
commit = client.create_commit(record.repository, message: commit_message(record), tree: tree, parents: [ parent ])
|
|
638
|
+
point_branch!(record, client, commit)
|
|
639
|
+
record.update!(head_commit: commit, compare_url: record.opened? ? nil : compare_url(record))
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# The commit the new one goes on top of: the checkout commit for a new
|
|
643
|
+
# branch, which must not exist yet, or the branch's head for an update,
|
|
644
|
+
# which must be the commit this record last published.
|
|
645
|
+
def expected_parent!(record, client)
|
|
646
|
+
head = client.branch_head(record.repository, record.branch)
|
|
647
|
+
if record.operation == "create"
|
|
648
|
+
raise Refused.new("A branch named #{record.branch} already exists on GitHub. Choose another name", code: "branch_exists") if head
|
|
649
|
+
return record.base_commit
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
if head.nil?
|
|
653
|
+
raise Refused.new("The branch #{record.branch} no longer exists on GitHub", code: "branch_moved")
|
|
654
|
+
elsif head != record.head_commit
|
|
655
|
+
raise Refused.new("The branch #{record.branch} has commits that were not published from this sandbox, so it was not updated",
|
|
656
|
+
code: "branch_moved")
|
|
657
|
+
end
|
|
658
|
+
head
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def point_branch!(record, client, commit)
|
|
662
|
+
if record.operation == "create"
|
|
663
|
+
client.create_branch(record.repository, record.branch, commit)
|
|
664
|
+
else
|
|
665
|
+
client.fast_forward_branch(record.repository, record.branch, commit)
|
|
666
|
+
end
|
|
667
|
+
rescue GithubClient::Error => e
|
|
668
|
+
raise unless e.status == 422
|
|
669
|
+
|
|
670
|
+
if record.operation == "create"
|
|
671
|
+
raise Refused.new("A branch named #{record.branch} already exists on GitHub. Choose another name", code: "branch_exists")
|
|
672
|
+
end
|
|
673
|
+
raise Refused.new("The branch #{record.branch} moved while it was updated, so it was not updated", code: "branch_moved")
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def open_pull_request!(record, client, draft:, allow_non_draft: false)
|
|
677
|
+
pull = client.create_pull_request(record.repository, title: record.title, body: record.body, head: record.branch,
|
|
678
|
+
base: record.base_branch, draft: draft)
|
|
679
|
+
record.update!(
|
|
680
|
+
status: "published", number: pull["number"], url: pull["html_url"], state: self.class.pull_state(pull),
|
|
681
|
+
draft: pull["draft"] == true, last_checked_at: Time.current, compare_url: nil, error_code: nil, error_message: nil
|
|
682
|
+
)
|
|
683
|
+
rescue GithubClient::Error => e
|
|
684
|
+
raise unless draft && draft_refused?(e)
|
|
685
|
+
return open_pull_request!(record, client, draft: false) if allow_non_draft
|
|
686
|
+
|
|
687
|
+
record.update!(
|
|
688
|
+
status: "draft_refused", compare_url: compare_url(record), error_code: "draft_refused",
|
|
689
|
+
error_message: "GitHub does not open draft pull requests in #{record.repository}. The branch #{record.branch} is published: " \
|
|
690
|
+
"open it as a regular pull request, or compare it on GitHub"
|
|
691
|
+
)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Whether GitHub refused to open a pull request because it was a draft,
|
|
695
|
+
# as its validation errors say, whatever the branch names in them.
|
|
696
|
+
def draft_refused?(error)
|
|
697
|
+
return false unless error.status == 422
|
|
698
|
+
|
|
699
|
+
error.errors.any? do |detail|
|
|
700
|
+
detail["field"] == "draft" || detail["message"].to_s.match?(/\Adraft pull requests are not (?:supported|available|enabled)/i)
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def commit_message(record)
|
|
705
|
+
return record.commit_message.presence || DraftPullRequest::DEFAULT_UPDATE_MESSAGE if record.operation == "update"
|
|
706
|
+
|
|
707
|
+
[ record.title, record.body.presence ].compact.join("\n\n")
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def compare_url(record)
|
|
711
|
+
encode = ->(ref) { ref.split("/").map { |part| ERB::Util.url_encode(part) }.join("/") }
|
|
712
|
+
"#{GithubClient::WEB}/#{record.repository}/compare/#{encode.call(record.base_branch)}...#{encode.call(record.branch)}?expand=1"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def commit_id?(value)
|
|
716
|
+
value.is_a?(String) && value.match?(GithubClient::OBJECT_ID)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
end
|