ci: re-run a single job in place (retry failed image without re-running test)
e499575 · gmorell · 2026-07-02 18:41
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