Skip to content

Commit b381290

Browse files
feat(front): start a job to commit changes from workflow editor (#155)
Introduces an option to commit changes made in the workflow editor through a Semaphore job that will use git CLI for these actions. The previous approach relies on libgit2 and API actions, which is unstable for large git repositories.
1 parent b9df3ed commit b381290

File tree

31 files changed

+1750
-239
lines changed

31 files changed

+1750
-239
lines changed

front/assets/js/workflow_editor/components/commit_panel.js

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import $ from "jquery";
22

33
import { CommitDialogTemplate } from "../templates/commit/dialog"
4+
import { Features } from "../../features";
45

56
function newDialogDiv() {
67
return $("<div style='display=none'>")[0]
@@ -79,7 +80,7 @@ export class CommitPanel {
7980

8081
_commit(e) {
8182
let branch = null;
82-
let message = null;
83+
let commitMessage = null;
8384

8485
var url = this.paths.commit;
8586

@@ -88,15 +89,21 @@ export class CommitPanel {
8889
$("[data-action=editorCommit]").addClass("btn-working");
8990
$("[data-action=editorCommit]").disabled = true;
9091

92+
var message = 'We are committing the changes to your git repository. ';
93+
message = message + 'This can take up to a few minutes.';
94+
$("#workflow-editor-commit-dialog-note").text(message);
95+
$("#workflow-editor-commit-dialog-note").addClass("dark-indigo");
96+
9197
let csrf = $("meta[name='csrf-token']").attr("content")
9298

9399
branch = $("#workflow-editor-commit-dialog-branch").val()
94-
message = $("#workflow-editor-commit-dialog-summary").val()
100+
commitMessage = $("#workflow-editor-commit-dialog-summary").val()
95101

96102
var body = new FormData();
97103
body.append("_csrf_token", csrf)
98104
body.append("branch", branch)
99-
body.append("commit_message", message)
105+
body.append("commit_message", commitMessage)
106+
body.append("initial_branch", this.initialBranch)
100107

101108
// if this is part of project onboarding, we need to also make sure
102109
// that the project onboarding finished signal is sent
@@ -146,17 +153,73 @@ export class CommitPanel {
146153
console.log(data)
147154

148155
if (data.wait)
149-
this.afterCommitHandler(branch, data.commit_sha);
156+
if(Features.isEnabled("useCommitJob"))
157+
this.commitJobHandler(branch, data.job_id);
158+
else {
159+
this.afterCommitHandler(branch, data.commit_sha);
160+
}
150161
else {
151162
this.dialog.innerHTML = CommitDialogTemplate.renderCommited(this.paths.dismiss);
152163
}
153164
})
154165
.catch(function(reason) {
155166
console.log(reason)
156167

157-
e.currentTarget.classList.remove("btn-working");
158-
e.currentTarget.disabled = false;
168+
this.resetButtonAndShowErrorMessage();
169+
})
170+
}
171+
172+
resetButtonAndShowErrorMessage() {
173+
$("[data-action=editorCommit]").removeClass("btn-working");
174+
$("[data-action=editorCommit]").disabled = false;
175+
176+
var message = "There was an issue with committing the changes to your git repository. "
177+
message = message + 'Please try again and contact support if the issue persists.';
178+
$("#workflow-editor-commit-dialog-note").text(message);
179+
$("#workflow-editor-commit-dialog-note").removeClass("dark-indigo");
180+
$("#workflow-editor-commit-dialog-note").addClass("red");
181+
}
182+
183+
commitJobHandler(branch, job_id) {
184+
var url = this.paths.checkCommitJob + `?job_id=${job_id}`
185+
186+
console.log(`Checking commit job ${url}`)
187+
188+
fetch(url)
189+
.then((res) => {
190+
var contentType = res.headers.get("content-type");
191+
192+
if(contentType && contentType.includes("application/json")) {
193+
return res.json();
194+
} else {
195+
throw new Error(res.statusText);
196+
}
197+
})
198+
.then((res) => {
199+
if(res.error) {
200+
throw new Error(res.error);
201+
} else {
202+
return res;
203+
}
204+
})
205+
.then((data) => {
206+
if(data.commit_sha === "") {
207+
setTimeout(this.commitJobHandler.bind(this, branch, job_id), 1000)
208+
} else {
209+
var message = 'The changes have been successfully committed. ';
210+
message = message + 'We will soon navigate you to the new workflow.';
211+
$("#workflow-editor-commit-dialog-note").text(message);
212+
213+
this.afterCommitHandler(branch, data.commit_sha);
214+
}
159215
})
216+
.catch(
217+
(reason) => {
218+
console.log(reason);
219+
220+
this.resetButtonAndShowErrorMessage();
221+
}
222+
)
160223
}
161224

162225
afterCommitHandler(branch, commitSha) {

front/assets/js/workflow_editor/editor.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export class WorkflowEditor {
3737
paths: {
3838
dismiss: InjectedDataByBackend.CommitForm.DismissPath,
3939
commit: InjectedDataByBackend.CommitForm.CommitPath,
40-
checkWorkflow: InjectedDataByBackend.CommitForm.CheckWorkflowPath
40+
checkWorkflow: InjectedDataByBackend.CommitForm.CheckWorkflowPath,
41+
checkCommitJob: InjectedDataByBackend.CommitForm.CheckCommitJobPath
4142
},
4243
pushBranch: InjectedDataByBackend.CommitForm.PushBranch,
4344
initialBranch: InjectedDataByBackend.CommitForm.InitialBranch,
@@ -56,6 +57,7 @@ export class WorkflowEditor {
5657
assertKey(config.commitInfo.paths, "dismiss", "Dismiss path is not configured")
5758
assertKey(config.commitInfo.paths, "commit", "Commit path is not configured")
5859
assertKey(config.commitInfo.paths, "checkWorkflow", "Check Workflow path is not configured")
60+
assertKey(config.commitInfo.paths, "checkCommitJob", "Check Job path is not configured")
5961

6062
//
6163
// If everything is cocher, start the editor.

front/assets/js/workflow_editor/templates/commit/dialog.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ function renderCommitPanel(workflow, commiterAvatar, initialBranch, pushBranch)
232232
</a>
233233
</div>`
234234

235-
let commitNote = `<p class="f6 measure-narrow mb1">
235+
let commitNote = `<p class="f6 w-100 w-two-thirds-m mb1" id=workflow-editor-commit-dialog-note>
236236
This will commit and push the configuration to repository and trigger the run on Semaphore.
237237
</p>`
238238

front/lib/front/models/artifacthub.ex

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,21 @@ defmodule Front.Models.Artifacthub do
8989
end)
9090
end
9191

92-
def signed_url(project_id, source_kind, source_id, relative_path, method \\ "GET") do
92+
def fetch_file(store_id, source_kind, source_id, relative_path) do
93+
with {:ok, url} <- signed_url_with_store_id(store_id, source_kind, source_id, relative_path),
94+
{:ok, response} <- HTTPoison.get(url),
95+
%{status_code: 200, body: content} <- response do
96+
{:ok, content}
97+
else
98+
%{status_code: 404, body: error} -> {:error, {:not_found, error}}
99+
error = {:error, _e} -> error
100+
error -> {:error, error}
101+
end
102+
end
103+
104+
def signed_url_with_store_id(store_id, source_kind, source_id, relative_path, method \\ "GET") do
93105
Watchman.benchmark("artifacthub.get_signed_url_request.duration", fn ->
94106
with req_path <- Resource.request_path(source_kind, source_id, relative_path),
95-
{:ok, store_id} <- get_artifact_store_id(project_id),
96107
{:ok, channel} <- GRPC.Stub.connect(api_endpoint()),
97108
request <-
98109
GetSignedURLRequest.new(artifact_id: store_id, path: req_path, method: method),
@@ -108,6 +119,19 @@ defmodule Front.Models.Artifacthub do
108119
end)
109120
end
110121

122+
def signed_url(project_id, source_kind, source_id, relative_path, method \\ "GET") do
123+
case get_artifact_store_id(project_id) do
124+
{:ok, store_id} ->
125+
signed_url_with_store_id(store_id, source_kind, source_id, relative_path, method)
126+
127+
e ->
128+
Watchman.increment("artifacthub.get_signed_url.failed")
129+
Logger.error("Failed to get url: #{inspect(e)}")
130+
131+
{:error, :grpc_req_failed}
132+
end
133+
end
134+
111135
defp parse_list_response(response, source_kind, source_id, path) do
112136
if Enum.empty?(response.items) && not Resource.root_path?(source_kind, source_id, path) do
113137
{:error, :non_existent_path}

front/lib/front/models/commit_job.ex

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
defmodule Front.Models.CommitJob do
2+
@moduledoc """
3+
Module encapsulates all functions needed to define the job used for commiting
4+
the changes made in workflow editor.
5+
"""
6+
7+
require Logger
8+
9+
alias Front.Models.User
10+
alias Front.Models.OrganizationSettings
11+
alias Front.Models.RepositoryIntegrator
12+
alias InternalApi.ServerFarm.Job.JobSpec
13+
alias InternalApi.Repository.CommitRequest.Change.Action
14+
15+
def get_agent(project) do
16+
case OrganizationSettings.fetch(project.organization_id) do
17+
{:ok, settings} -> decide_on_agent(settings)
18+
error -> {:error, :fetch_agent, error}
19+
end
20+
end
21+
22+
defp decide_on_agent(%{"custom_machine_type" => type, "custom_os_image" => os_image})
23+
when is_binary(type) and type != "" do
24+
{:ok, %{type: type, os_image: os_image}}
25+
end
26+
27+
defp decide_on_agent(%{"plan_machine_type" => type, "plan_os_image" => os_image})
28+
when is_binary(type) and type != "" do
29+
{:ok, %{type: type, os_image: os_image}}
30+
end
31+
32+
defp decide_on_agent(_seetings), do: {:error, :settings_without_agent_def}
33+
34+
def get_git_credentials(project, user_id) do
35+
case project.integration_type do
36+
:GITHUB_OAUTH_TOKEN ->
37+
get_creds_from_repository_integrator(project, user_id, "x-oauth-token")
38+
39+
:GITHUB_APP ->
40+
get_creds_from_repository_integrator(project, user_id, "x-access-token")
41+
42+
:BITBUCKET ->
43+
get_creds_from_user_service(project, user_id, "x-token-auth")
44+
45+
:GITLAB ->
46+
get_creds_from_user_service(project, user_id, "oauth2")
47+
end
48+
end
49+
50+
defp get_creds_from_repository_integrator(project, user_id, git_username) do
51+
case RepositoryIntegrator.get_repository_token(project, user_id) do
52+
{:ok, token} -> {:ok, %{username: git_username, token: token}}
53+
error -> {:error, :token_from_repo_integrator, error}
54+
end
55+
end
56+
57+
defp get_creds_from_user_service(project, user_id, git_username) do
58+
case User.get_repository_token(project, user_id) do
59+
{:ok, token} -> {:ok, %{username: git_username, token: token}}
60+
error -> {:error, :token_from_user_svc, error}
61+
end
62+
end
63+
64+
def create_job_spec(agent, creds, params) do
65+
{:ok,
66+
%JobSpec{
67+
job_name: "Commiting changes from workflow editor to branch #{params.target_branch}",
68+
agent: %JobSpec.Agent{
69+
machine: %JobSpec.Agent.Machine{
70+
os_image: agent.os_image,
71+
type: agent.type
72+
},
73+
containers: [],
74+
image_pull_secrets: []
75+
},
76+
secrets: [],
77+
env_vars: [],
78+
files: files_for_creds(creds) ++ modified_files(params.changes),
79+
commands: generate_commands(params),
80+
epilogue_always_commands: [],
81+
epilogue_on_pass_commands: [],
82+
epilogue_on_fail_commands: [],
83+
priority: 95,
84+
execution_time_limit: 10
85+
}}
86+
end
87+
88+
defp files_for_creds(creds) do
89+
[
90+
%JobSpec.File{
91+
path: ".workflow_editor/git_username.txt",
92+
content: Base.encode64(creds.username)
93+
},
94+
%JobSpec.File{
95+
path: ".workflow_editor/git_password.txt",
96+
content: Base.encode64(creds.token)
97+
}
98+
]
99+
end
100+
101+
defp modified_files(changes) do
102+
Enum.reduce(changes, [], fn change, acc ->
103+
if change.action != Action.value(:DELETE_FILE) do
104+
file = %JobSpec.File{
105+
path: ".changed_files/#{change.file.path}",
106+
content: Base.encode64(change.file.content)
107+
}
108+
109+
acc ++ [file]
110+
else
111+
acc
112+
end
113+
end)
114+
end
115+
116+
defp generate_commands(params) do
117+
[
118+
# Read git credentials from files
119+
"export GIT_USERNAME=$(cat .workflow_editor/git_username.txt)",
120+
"export GIT_PASSWORD=$(cat .workflow_editor/git_password.txt)",
121+
# Prepare URL used for pushing with temporary user access token as a credential
122+
"export GIT_REPO_URL=\"${SEMAPHORE_GIT_URL/://}\"",
123+
"export GIT_REPO_URL=\"${GIT_REPO_URL/git@/https:\/\/$GIT_USERNAME:$GIT_PASSWORD@}\"",
124+
# Configure the branch that checkout should use
125+
"export SEMAPHORE_GIT_BRANCH=#{params.initial_branch}",
126+
# Clone repository using the read-only deploy key
127+
"checkout",
128+
# switch to a new branch if user configured a different target branch
129+
configure_target_branch(params.initial_branch, params.target_branch),
130+
# Add modified files and remove deleted ones
131+
add_commands_for_modified_files(params),
132+
add_commands_for_deleted_files(params),
133+
# Configure the user to be author of the changes
134+
"git config --global user.name #{params.user.name}",
135+
"git config --global user.email #{params.user.email}",
136+
# Create commit with changes
137+
"git add .",
138+
"git commit -m \"#{params.commit_message}\"",
139+
# Push commit to remote repository
140+
"git push $GIT_REPO_URL HEAD",
141+
# save the new commit sha in artifacts
142+
"git rev-parse HEAD > commit_sha.val",
143+
"artifact push job commit_sha.val -d .workflow_editor/commit_sha.val"
144+
]
145+
|> List.flatten()
146+
|> Enum.filter(fn elem -> elem != :skip end)
147+
end
148+
149+
defp configure_target_branch(initial, target) do
150+
if initial == target do
151+
:skip
152+
else
153+
"git checkout -b #{target}"
154+
end
155+
end
156+
157+
defp add_commands_for_modified_files(params) do
158+
Enum.reduce(params.changes, [], fn change, acc ->
159+
if change.action != Action.value(:DELETE_FILE) do
160+
acc ++ ["mv ../.changed_files/#{change.file.path} ./#{change.file.path}"]
161+
else
162+
acc
163+
end
164+
end)
165+
end
166+
167+
defp add_commands_for_deleted_files(params) do
168+
Enum.reduce(params.changes, [], fn change, acc ->
169+
if change.action == Action.value(:DELETE_FILE) do
170+
acc ++ ["rm #{change.file.path} || true"]
171+
else
172+
acc
173+
end
174+
end)
175+
end
176+
end

0 commit comments

Comments
 (0)