ci: re-run a single job in place (retry failed image without re-running test)

e499575 · gmorell · 2026-07-02 18:41

2 files +102 -0

Files changed

modified lib/git_gud/workflows.ex
+71 −0
@@ -1134,6 +1134,77 @@ defmodule GitGud.Workflows do
1134 1134 })
1135 1135 end
1136 1136
1137 + @doc """
1138 + Re-run a single job *in place* (e.g. retry a failed `image` without
1139 + re-running the 10-minute upstream `test`). Resets the job and its steps
1140 + to pending, clears its logs, re-queues it (`queued` when its `needs:`
1141 + are already satisfied, else `blocked`), and flips the run back to
1142 + `running` so the runner re-claims it. Upstream jobs are untouched.
1143 + """
1144 + def rerun_job(%WorkflowRun{} = run, job_id) when is_binary(job_id) do
1145 + siblings = Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^run.id)
1146 +
1147 + case Enum.find(siblings, &(&1.job_id == job_id)) do
1148 + nil ->
1149 + {:error, :not_found}
1150 +
1151 + job ->
1152 + by_jid = Map.new(siblings, &{&1.job_id, &1})
1153 +
1154 + needs_ok? =
1155 + job.needs == [] or
1156 + Enum.all?(job.needs, fn jid ->
1157 + case Map.get(by_jid, jid) do
1158 + %WorkflowJob{status: s} -> WorkflowJob.satisfies_dependency?(s)
1159 + _ -> false
1160 + end
1161 + end)
1162 +
1163 + Repo.transaction(fn ->
1164 + # clean slate for this job's logs + steps
1165 + Repo.delete_all(from l in "workflow_step_logs", where: l.workflow_job_id == ^job.id)
1166 +
1167 + Repo.update_all(
1168 + from(s in WorkflowStep, where: s.workflow_job_id == ^job.id),
1169 + set: [
1170 + status: "pending",
1171 + conclusion: nil,
1172 + started_at: nil,
1173 + completed_at: nil,
1174 + log_index: nil,
1175 + log_length: nil
1176 + ]
1177 + )
1178 +
1179 + {:ok, updated} =
1180 + job
1181 + |> Ecto.Changeset.change(
1182 + status: if(needs_ok?, do: "queued", else: "blocked"),
1183 + conclusion: nil,
1184 + started_at: nil,
1185 + completed_at: nil,
1186 + runner_id: nil
1187 + )
1188 + |> Repo.update()
1189 +
1190 + # Flip the run non-terminal so it isn't "complete" and the
1191 + # runner polls it. maybe_complete_run re-settles it on finish.
1192 + run |> WorkflowRun.status_changeset("running") |> Repo.update!()
1193 +
1194 + updated
1195 + end)
1196 + |> case do
1197 + {:ok, updated} ->
1198 + broadcast({:job_status, run.id, updated})
1199 + broadcast_ci_change(run.id)
1200 + {:ok, Repo.get!(WorkflowRun, run.id)}
1201 +
1202 + {:error, _} = err ->
1203 + err
1204 + end
1205 + end
1206 + end
1207 +
1137 1208 @doc """
1138 1209 Cancel a queued or running workflow run. Flips the run + every
1139 1210 un-finished job + every pending/running step to `cancelled`. Runners
modified lib/git_gud_web/live/workflow_live/show.ex
+31 −0
@@ -178,6 +178,27 @@ defmodule GitGudWeb.WorkflowLive.Show do
178 178 end
179 179 end
180 180
181 + def handle_event("rerun_job", %{"job" => job_id}, socket) do
182 + cond do
183 + not socket.assigns.can_admin? ->
184 + {:noreply, put_flash(socket, :error, "Only repo admins can re-run jobs.")}
185 +
186 + true ->
187 + case Workflows.rerun_job(socket.assigns.run, job_id) do
188 + {:ok, _run} ->
189 + run = Workflows.get_run!(socket.assigns.repo, socket.assigns.run.number)
190 +
191 + {:noreply,
192 + socket
193 + |> put_flash(:info, "Re-running job #{job_id}.")
194 + |> assign(:run, run)}
195 +
196 + {:error, reason} ->
197 + {:noreply, put_flash(socket, :error, "Could not re-run job: #{inspect(reason)}")}
198 + end
199 + end
200 + end
201 +
181 202 def handle_event("cancel", _params, socket) do
182 203 cond do
183 204 not socket.assigns.can_admin? ->
@@ -273,6 +294,16 @@ defmodule GitGudWeb.WorkflowLive.Show do
273 294 <div class="flex items-baseline gap-2 text-xs opacity-60">
274 295 <span :if={job.started_at}>{duration(job.started_at, job.completed_at)}</span>
275 296 <span class={["badge badge-sm", status_class(job.status)]}>{job.status}</span>
297 + <button
298 + :if={@can_admin? and job.status in ["failure", "cancelled", "success", "skipped"]}
299 + type="button"
300 + phx-click="rerun_job"
301 + phx-value-job={job.job_id}
302 + class="btn btn-ghost btn-xs"
303 + title={"Re-run only #{job.job_id} (keeps upstream jobs)"}
304 + >
305 + <.icon name="hero-arrow-path" class="size-3" /> Re-run job
306 + </button>
276 307 </div>
277 308 </header>
278 309

Parents: 9052a6b