✨ (ci): Add run cancel and re-run
Changes
10 files changed, +537 -9
MODIFY
README.md
+2 -2
@@ -90,8 +90,8 @@
90
90
which a runner claims, executes, and streams logs for — visible on the repository's **Actions** tab;
91
91
a vanished runner's task is reclaimed after a timeout. Jobs are matched to runners by `runs-on`
92
92
labels, ordered by `needs` dependencies, and repository owners manage encrypted secrets and
93
- variables that are delivered to runners. Non-push events, `matrix`, and artifacts are follow-up
94
- phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
93
+ variables that are delivered to runners, and runs can be cancelled or re-run from the UI.
94
+ Non-push events, `matrix`, and artifacts are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
95
95
[architecture](docs/maintainers/ci-runners.md)
96
96
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
97
97
public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY
docs/maintainers/ci-runners.md
+14 -3
@@ -15,6 +15,7 @@
15
15
| Task dispatch | `ci/TaskDispatchService.java` | FetchTask: authenticate, claim the oldest PENDING task, flip task+run to RUNNING, set the runner ACTIVE and the task deadline — atomically. |
16
16
| Task progress | `ci/TaskProgressService.java` | UpdateTask (result → task status + run roll-up, runner back to IDLE) and UpdateLog (resume-safe log-row append, `ack_index`). |
17
17
| Zombie reclaim | `ci/ZombieReclaimService.java` | Scheduled sweep failing RUNNING tasks past their deadline (vanished runner) and rolling up their runs. |
18
+| Run controls | `ci/ActionRunService.java` | Cancel a run (settle run + unfinished tasks) and re-run a finished run (reset tasks to PENDING, clear logs/outputs). |
18
19
| Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
19
20
| Secrets/variables UI | `web/ActionSettingsResource.java` + `ci/ActionSecretService.java` + `templates/ActionSettingsResource/` | Owner-only CRUD for CI secrets (write-only, encrypted) and variables at `settings/actions`. |
20
21
| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
@@ -120,13 +121,22 @@
120
121
variable, duplicate-name rejected, stranger/anonymous get 404),
121
122
`NeedsOrderingTest` (dependent waits for its need then receives its result; a failed need cancels
122
123
the dependent and ends the run), `NeedsOutputsTest` (dependent receives an upstream job's outputs;
123
- outputs accumulate across incremental UpdateTask calls).
124
+ outputs accumulate across incremental UpdateTask calls),
125
+ `CancelRerunTest` (cancel settles run+unfinished tasks, re-run resets a finished run, a cancelled
126
+ task tells the runner to stop via UpdateTask) and `ActionControlUiTest` (owner cancels/re-runs over
127
+ HTTP, a non-writer is refused).
124
128
- **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
125
129
(`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
126
130
`action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
127
131
runner OFFLINE. The deadline is set at claim time from `gitshark.ci.task-timeout` (default 1h). A
128
132
late update from a runner cannot resurrect an already-terminal task. `ZombieReclaimTest` covers both
129
133
the reclaim (overdue → FAILURE, in-deadline left RUNNING) and the anti-resurrection guard.
134
+- **Cancel & re-run:** `ActionRunService.cancel` settles a run and its unfinished tasks; a task still
135
+ running on a runner keeps its assignment but is told to stop the next time it calls UpdateTask (the
136
+ response's `TaskState.result` is flipped to CANCELLED). `rerun` resets a finished run's tasks to
137
+ PENDING (clearing runner, timing, logs and outputs) so they are picked up fresh. Both re-fetch the
138
+ run inside the transaction (the entity arrives detached from the resource) and are gated on
139
+ repository write access at `POST .../actions/{n}/cancel` and `.../rerun` (buttons on the run page).
130
140
- **Actions UI:** a read-only `Actions` tab on each repository — `ActionResource` renders a run list
131
141
(workflow, run number, status, event, short commit) and a run detail page with each job and its
132
142
streamed log rows. Read-gated like the rest of the repo UI (404 for a hidden repo). Tested by
@@ -149,8 +159,9 @@
149
159
not. (`!`-negation within a single pattern list is also not supported.)
150
160
- **`matrix`:** expansion is not implemented — a job with `strategy.matrix` runs once, not once per
151
161
cell (needs a per-job/per-cell payload expander).
152
-- **Later phases:** concurrency/cancellation, artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and
153
- ephemeral runners, commit/MR status, non-push events.
162
+- **Concurrency:** no auto-cancel of superseded runs on force-push (manual cancel/re-run only).
163
+- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners,
164
+ commit/MR status, non-push events.
154
165
155
166
## References
156
167
MODIFY
docs/users/ci-runners.md
+5 -1
@@ -36,6 +36,10 @@
36
36
A run whose runner disappears mid-job is marked **Failure** once its time limit passes (configurable
37
37
by the admin), so a run never hangs as Running forever.
38
38
39
+Users with write access see controls on the run page: **Cancel run** (while it is still running —
40
+settles the run and tells the active runner to stop) and **Re-run** (on a finished run — resets its
41
+jobs and runs them again).
42
+
39
43
## Trigger filters
40
44
41
45
Beyond a bare `on: push` (which runs on every branch push), you can scope runs to specific refs:
@@ -89,5 +93,5 @@
89
93
## What's coming
90
94
91
95
- Non-push events (`pull_request`, scheduled, manual), `matrix`.
92
-- Run cancellation / re-run.
96
+- Auto-cancelling superseded runs on force-push.
93
97
- Artifacts and commit/merge-request status integration.
ADD
src/main/java/de/workaround/ci/ActionRunService.java
+83 -0
@@ -0,0 +1,83 @@
1
+package de.workaround.ci;
2
+
3
+import java.time.Instant;
4
+
5
+import de.workaround.model.ActionLog;
6
+import de.workaround.model.ActionRun;
7
+import de.workaround.model.ActionTask;
8
+import jakarta.enterprise.context.ApplicationScoped;
9
+import jakarta.inject.Inject;
10
+import jakarta.transaction.Transactional;
11
+
12
+/**
13
+ * Run-level controls: cancel and re-run (issue #2, phase 2). Callers must have already gated on
14
+ * repository write access.
15
+ */
16
+@ApplicationScoped
17
+public class ActionRunService
18
+{
19
+ @Inject
20
+ ActionRun.Repo runs;
21
+
22
+ @Inject
23
+ ActionTask.Repo tasks;
24
+
25
+ @Inject
26
+ ActionLog.Repo logs;
27
+
28
+ /**
29
+ * Cancel a run: settle the run and every task that has not already finished. A task still RUNNING
30
+ * on a runner keeps its assignment but is marked CANCELLED; the runner is told to stop the next
31
+ * time it calls UpdateTask (see {@code ConnectRunnerResource}).
32
+ */
33
+ @Transactional
34
+ public void cancel(ActionRun detached)
35
+ {
36
+ ActionRun run = runs.findById(detached.id);
37
+ if (run.status.isTerminal())
38
+ {
39
+ // already finished (naturally or a double click on a stale page) — don't rewrite history
40
+ return;
41
+ }
42
+ Instant now = Instant.now();
43
+ for (ActionTask task : tasks.findByRun(run))
44
+ {
45
+ if (!task.status.isTerminal())
46
+ {
47
+ task.status = ActionRun.Status.CANCELLED;
48
+ task.finishedAt = now;
49
+ }
50
+ }
51
+ run.status = ActionRun.Status.CANCELLED;
52
+ run.finishedAt = now;
53
+ }
54
+
55
+ /**
56
+ * Re-run a finished run: reset every task to PENDING (clearing runner assignment, timing, logs and
57
+ * outputs) and the run to PENDING, so runners pick the work up fresh. No-op if the run is not
58
+ * terminal.
59
+ */
60
+ @Transactional
61
+ public void rerun(ActionRun detached)
62
+ {
63
+ ActionRun run = runs.findById(detached.id);
64
+ if (!run.status.isTerminal())
65
+ {
66
+ return;
67
+ }
68
+ for (ActionTask task : tasks.findByRun(run))
69
+ {
70
+ logs.deleteByTask(task);
71
+ task.status = ActionRun.Status.PENDING;
72
+ task.runner = null;
73
+ task.startedAt = null;
74
+ task.finishedAt = null;
75
+ task.deadline = null;
76
+ task.logLength = 0;
77
+ task.outputs = "{}";
78
+ }
79
+ run.status = ActionRun.Status.PENDING;
80
+ run.startedAt = null;
81
+ run.finishedAt = null;
82
+ }
83
+}
MODIFY
src/main/java/de/workaround/ci/ConnectRunnerResource.java
+6 -1
@@ -18,6 +18,7 @@
18
18
import de.workaround.ci.proto.runner.v1.FetchTaskResponse;
19
19
import de.workaround.ci.proto.runner.v1.RegisterRequest;
20
20
import de.workaround.ci.proto.runner.v1.RegisterResponse;
21
+import de.workaround.ci.proto.runner.v1.Result;
21
22
import de.workaround.ci.proto.runner.v1.Runner;
22
23
import de.workaround.ci.proto.runner.v1.RunnerStatus;
23
24
import de.workaround.ci.proto.runner.v1.Task;
@@ -147,8 +148,12 @@
147
148
{
148
149
ActionTask task = progressService.updateTask(uuid, token, state.getId(), state.getResult(), stoppedAt,
149
150
request.getOutputsMap());
151
+ // If the task was cancelled server-side (e.g. the run was cancelled), tell the runner to stop.
152
+ TaskState responseState = task.status == ActionRun.Status.CANCELLED
153
+ ? state.toBuilder().setResult(Result.RESULT_CANCELLED).build()
154
+ : state;
150
155
return ok(UpdateTaskResponse.newBuilder()
151
- .setState(state)
156
+ .setState(responseState)
152
157
.addAllSentOutputs(ActionOutputs.parse(task.outputs).keySet())
153
158
.build().toByteArray());
154
159
}
MODIFY
src/main/java/de/workaround/model/ActionLog.java
+3 -0
@@ -45,6 +45,9 @@
45
45
46
46
@HQL("select count(l) from ActionLog l where l.task = :task")
47
47
long countByTask(ActionTask task);
48
+
49
+ @HQL("delete from ActionLog l where l.task = :task")
50
+ void deleteByTask(ActionTask task);
48
51
}
49
52
50
53
}
MODIFY
src/main/java/de/workaround/web/ActionResource.java
+48 -2
@@ -1,9 +1,12 @@
1
1
package de.workaround.web;
2
2
3
+import java.net.URI;
3
4
import java.util.List;
4
5
5
6
import de.workaround.account.CurrentUser;
7
+import de.workaround.ci.ActionRunService;
6
8
import de.workaround.git.AccessPolicy;
9
+import de.workaround.git.ForbiddenOperationException;
7
10
import de.workaround.git.GitRepositoryService;
8
11
import de.workaround.model.ActionLog;
9
12
import de.workaround.model.ActionRun;
@@ -14,10 +17,12 @@
14
17
import jakarta.inject.Inject;
15
18
import jakarta.ws.rs.GET;
16
19
import jakarta.ws.rs.NotFoundException;
20
+import jakarta.ws.rs.POST;
17
21
import jakarta.ws.rs.PathParam;
18
22
import jakarta.ws.rs.Produces;
19
23
import jakarta.ws.rs.core.Context;
20
24
import jakarta.ws.rs.core.MediaType;
25
+import jakarta.ws.rs.core.Response;
21
26
import jakarta.ws.rs.core.UriInfo;
22
27
23
28
/**
@@ -34,7 +39,8 @@
34
39
{
35
40
static native TemplateInstance runs(Repository repo, RepoNav nav, List<ActionRun> runs);
36
41
37
- static native TemplateInstance run(Repository repo, RepoNav nav, ActionRun run, List<TaskLogs> tasks);
42
+ static native TemplateInstance run(Repository repo, RepoNav nav, ActionRun run, List<TaskLogs> tasks,
43
+ boolean canWrite);
38
44
}
39
45
40
46
/** A task paired with its log rows, for the run detail page. */
@@ -63,6 +69,9 @@
63
69
@Inject
64
70
ActionLog.Repo logs;
65
71
72
+ @Inject
73
+ ActionRunService runService;
74
+
66
75
@Context
67
76
UriInfo uriInfo;
68
77
@@ -83,7 +92,44 @@
83
92
List<TaskLogs> taskLogs = tasks.findByRun(run).stream()
84
93
.map(task -> new TaskLogs(task, logs.findByTask(task)))
85
94
.toList();
86
- return Templates.run(repo, repoNav.build(repo, uriInfo), run, taskLogs);
95
+ boolean canWrite = accessPolicy.canWrite(currentUser.get(), repo);
96
+ return Templates.run(repo, repoNav.build(repo, uriInfo), run, taskLogs, canWrite);
97
+ }
98
+
99
+ @POST
100
+ @jakarta.ws.rs.Path("{number:\\d+}/cancel")
101
+ public Response cancel(@PathParam("owner") String owner, @PathParam("name") String name,
102
+ @PathParam("number") int number)
103
+ {
104
+ ActionRun run = requireWritableRun(owner, name, number);
105
+ runService.cancel(run);
106
+ return backToRun(run, number);
107
+ }
108
+
109
+ @POST
110
+ @jakarta.ws.rs.Path("{number:\\d+}/rerun")
111
+ public Response rerun(@PathParam("owner") String owner, @PathParam("name") String name,
112
+ @PathParam("number") int number)
113
+ {
114
+ ActionRun run = requireWritableRun(owner, name, number);
115
+ runService.rerun(run);
116
+ return backToRun(run, number);
117
+ }
118
+
119
+ private ActionRun requireWritableRun(String owner, String name, int number)
120
+ {
121
+ Repository repo = requireReadable(owner, name);
122
+ if (!accessPolicy.canWrite(currentUser.get(), repo))
123
+ {
124
+ throw new ForbiddenOperationException("Only users with write access can control runs");
125
+ }
126
+ return runs.findByRepositoryAndNumber(repo, number).orElseThrow(NotFoundException::new);
127
+ }
128
+
129
+ private static Response backToRun(ActionRun run, int number)
130
+ {
131
+ return Response.seeOther(URI.create(
132
+ "/repos/" + run.repository.ownerHandle() + "/" + run.repository.name + "/actions/" + number)).build();
87
133
}
88
134
89
135
private Repository requireReadable(String owner, String name)
MODIFY
src/main/resources/templates/ActionResource/run.html
+14 -0
@@ -6,6 +6,20 @@
6
6
<p class="issue-back"><a href="/repos/{repo.ownerHandle}/{repo.name}/actions">← Actions</a></p>
7
7
<header class="issue-header">
8
8
<h2 class="issue-heading">{run.workflowName} <span class="issue-no">#{run.number}</span></h2>
9
+ {#if canWrite}
10
+ <div class="issue-header-actions">
11
+ {#if run.status.terminal}
12
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/actions/{run.number}/rerun"
13
+ onsubmit="return confirm('Re-run this workflow? Its previous logs will be cleared.')">
14
+ <button type="submit" class="btn btn-secondary btn-sm">Re-run</button>
15
+ </form>
16
+ {#else}
17
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/actions/{run.number}/cancel">
18
+ <button type="submit" class="btn btn-danger btn-sm">Cancel run</button>
19
+ </form>
20
+ {/if}
21
+ </div>
22
+ {/if}
9
23
</header>
10
24
<div class="issue-byline">
11
25
<span class="badge status-{run.status}">{run.status.label}</span>
ADD
src/test/java/de/workaround/ci/CancelRerunTest.java
+237 -0
@@ -0,0 +1,237 @@
1
+package de.workaround.ci;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import com.google.protobuf.InvalidProtocolBufferException;
10
+
11
+import de.workaround.ci.proto.runner.v1.Result;
12
+import de.workaround.ci.proto.runner.v1.TaskState;
13
+import de.workaround.ci.proto.runner.v1.UpdateTaskRequest;
14
+import de.workaround.ci.proto.runner.v1.UpdateTaskResponse;
15
+import de.workaround.git.GitRepositoryService;
16
+import de.workaround.model.ActionLog;
17
+import de.workaround.model.ActionRun;
18
+import de.workaround.model.ActionTask;
19
+import de.workaround.model.CiRunner;
20
+import de.workaround.model.Repository;
21
+import de.workaround.model.User;
22
+import io.quarkus.test.TestTransaction;
23
+import io.quarkus.test.junit.QuarkusTest;
24
+import jakarta.inject.Inject;
25
+import jakarta.persistence.EntityManager;
26
+import jakarta.transaction.Transactional;
27
+
28
+import static io.restassured.RestAssured.given;
29
+import static org.junit.jupiter.api.Assertions.assertEquals;
30
+import static org.junit.jupiter.api.Assertions.assertNull;
31
+import static org.junit.jupiter.api.Assertions.assertTrue;
32
+
33
+/**
34
+ * Run cancellation and re-run (issue #2, phase 2). Cancelling settles a run and its unfinished tasks,
35
+ * and a still-running task's runner learns of it via the UpdateTask response. Re-run resets a finished
36
+ * run's tasks back to PENDING.
37
+ */
38
+@QuarkusTest
39
+class CancelRerunTest
40
+{
41
+ private static final String PROTO = "application/proto";
42
+
43
+ @Inject
44
+ ActionRunService runService;
45
+
46
+ @Inject
47
+ RunnerRegistrationService runnerService;
48
+
49
+ @Inject
50
+ GitRepositoryService repositories;
51
+
52
+ @Inject
53
+ ActionRun.Repo runs;
54
+
55
+ @Inject
56
+ ActionTask.Repo tasks;
57
+
58
+ @Inject
59
+ ActionLog.Repo logs;
60
+
61
+ @Inject
62
+ CiRunner.Repo runners;
63
+
64
+ @Inject
65
+ EntityManager em;
66
+
67
+ @Test
68
+ @TestTransaction
69
+ void cancelSettlesRunAndUnfinishedTasks()
70
+ {
71
+ Repository repo = repo("cr-a");
72
+ ActionRun run = newRun(repo, ActionRun.Status.RUNNING);
73
+ UUID running = newTask(run, "build", ActionRun.Status.RUNNING);
74
+ UUID pending = newTask(run, "deploy", ActionRun.Status.PENDING);
75
+
76
+ runService.cancel(run);
77
+
78
+ assertEquals(ActionRun.Status.CANCELLED, runs.findById(run.id).status);
79
+ assertEquals(ActionRun.Status.CANCELLED, tasks.findById(running).status);
80
+ assertEquals(ActionRun.Status.CANCELLED, tasks.findById(pending).status);
81
+ }
82
+
83
+ @Test
84
+ @TestTransaction
85
+ void rerunResetsFinishedRunToPending()
86
+ {
87
+ Repository repo = repo("cr-b");
88
+ ActionRun run = newRun(repo, ActionRun.Status.SUCCESS);
89
+ run.finishedAt = Instant.now();
90
+ UUID taskId = newTask(run, "build", ActionRun.Status.SUCCESS);
91
+ ActionTask task = tasks.findById(taskId);
92
+ task.finishedAt = Instant.now();
93
+ task.logLength = 2;
94
+ task.outputs = "{\"image\":\"x\"}";
95
+ ActionLog log = new ActionLog();
96
+ log.task = task;
97
+ log.lineIndex = 0;
98
+ log.content = "hi";
99
+ log.persist();
100
+
101
+ runService.rerun(run);
102
+
103
+ ActionRun reloaded = runs.findById(run.id);
104
+ ActionTask reloadedTask = tasks.findById(taskId);
105
+ assertEquals(ActionRun.Status.PENDING, reloaded.status);
106
+ assertNull(reloaded.finishedAt);
107
+ assertEquals(ActionRun.Status.PENDING, reloadedTask.status);
108
+ assertNull(reloadedTask.runner);
109
+ assertNull(reloadedTask.finishedAt);
110
+ assertEquals(0, reloadedTask.logLength);
111
+ assertEquals("{}", reloadedTask.outputs);
112
+ assertTrue(logs.findByTask(reloadedTask).isEmpty(), "old logs cleared");
113
+ }
114
+
115
+ @Test
116
+ @TestTransaction
117
+ void cancelIsNoOpOnAlreadyFinishedRun()
118
+ {
119
+ Repository repo = repo("cr-d");
120
+ ActionRun run = newRun(repo, ActionRun.Status.SUCCESS);
121
+ Instant finished = Instant.now().minusSeconds(60);
122
+ run.finishedAt = finished;
123
+
124
+ runService.cancel(run);
125
+
126
+ ActionRun reloaded = runs.findById(run.id);
127
+ assertEquals(ActionRun.Status.SUCCESS, reloaded.status, "a finished run is not rewritten to CANCELLED");
128
+ assertEquals(finished, reloaded.finishedAt);
129
+ }
130
+
131
+ @Test
132
+ @TestTransaction
133
+ void rerunIsNoOpWhileRunStillRunning()
134
+ {
135
+ Repository repo = repo("cr-e");
136
+ ActionRun run = newRun(repo, ActionRun.Status.RUNNING);
137
+ UUID taskId = newTask(run, "build", ActionRun.Status.RUNNING);
138
+
139
+ runService.rerun(run);
140
+
141
+ assertEquals(ActionRun.Status.RUNNING, runs.findById(run.id).status, "a running run is not reset");
142
+ assertEquals(ActionRun.Status.RUNNING, tasks.findById(taskId).status);
143
+ }
144
+
145
+ @Test
146
+ void cancelledTaskTellsRunnerToStopOnUpdate() throws InvalidProtocolBufferException
147
+ {
148
+ RunnerRegistrationService.RegisteredRunner reg = registerRunner();
149
+ long seq = seedClaimedThenCancelled("cr-c", reg.runner().uuid);
150
+
151
+ byte[] bytes = given()
152
+ .contentType(PROTO)
153
+ .header("x-runner-uuid", reg.runner().uuid)
154
+ .header("x-runner-token", reg.plaintext())
155
+ .body(UpdateTaskRequest.newBuilder()
156
+ .setState(TaskState.newBuilder().setId(seq).setResult(Result.RESULT_UNSPECIFIED))
157
+ .build().toByteArray())
158
+ .when().post("/api/actions/runner.v1.RunnerService/UpdateTask")
159
+ .then().statusCode(200)
160
+ .extract().asByteArray();
161
+
162
+ UpdateTaskResponse response = UpdateTaskResponse.parseFrom(bytes);
163
+ assertEquals(Result.RESULT_CANCELLED, response.getState().getResult(),
164
+ "the runner is told the task was cancelled");
165
+ }
166
+
167
+ @Transactional
168
+ long seedClaimedThenCancelled(String repoName, String runnerUuid)
169
+ {
170
+ Repository repo = repo(repoName);
171
+ CiRunner runner = runners.findByUuid(runnerUuid).orElseThrow();
172
+ ActionRun run = newRun(repo, ActionRun.Status.RUNNING);
173
+ ActionTask task = new ActionTask();
174
+ task.run = run;
175
+ task.name = "build";
176
+ task.payload = "on: push";
177
+ task.runner = runner;
178
+ task.status = ActionRun.Status.RUNNING;
179
+ task.persist();
180
+ em.flush();
181
+ runService.cancel(run);
182
+ return task.seq;
183
+ }
184
+
185
+ private RunnerRegistrationService.RegisteredRunner registerRunner()
186
+ {
187
+ String token = runnerService.createRegistrationToken(persistUser("cr-admin-" + shortId())).plaintext();
188
+ return runnerService.register(token, "cr-runner", List.of(), "v4.0.0", false);
189
+ }
190
+
191
+ private Repository repo(String name)
192
+ {
193
+ User owner = persistUser(name + "-" + shortId());
194
+ return repositories.create(owner, name, Repository.Visibility.PUBLIC, null);
195
+ }
196
+
197
+ private ActionRun newRun(Repository repo, ActionRun.Status status)
198
+ {
199
+ ActionRun run = new ActionRun();
200
+ run.repository = repo;
201
+ run.number = runs.maxNumber(repo) + 1;
202
+ run.workflowName = "CI";
203
+ run.workflowFile = ".forgejo/workflows/ci.yml";
204
+ run.event = "push";
205
+ run.ref = "refs/heads/main";
206
+ run.commitSha = "0000000000000000000000000000000000000000";
207
+ run.status = status;
208
+ run.persist();
209
+ return run;
210
+ }
211
+
212
+ private UUID newTask(ActionRun run, String name, ActionRun.Status status)
213
+ {
214
+ ActionTask task = new ActionTask();
215
+ task.run = run;
216
+ task.name = name;
217
+ task.payload = "on: push";
218
+ task.status = status;
219
+ task.persist();
220
+ return task.id;
221
+ }
222
+
223
+ @Transactional
224
+ User persistUser(String name)
225
+ {
226
+ User user = new User();
227
+ user.oidcSub = name;
228
+ user.username = name;
229
+ user.persist();
230
+ return user;
231
+ }
232
+
233
+ private static String shortId()
234
+ {
235
+ return UUID.randomUUID().toString().substring(0, 8);
236
+ }
237
+}
ADD
src/test/java/de/workaround/web/ActionControlUiTest.java
+125 -0
@@ -0,0 +1,125 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.ActionRun;
9
+import de.workaround.model.Repository;
10
+import de.workaround.model.User;
11
+import io.quarkus.test.junit.QuarkusTest;
12
+import io.quarkus.test.security.TestSecurity;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+import static io.restassured.RestAssured.given;
17
+import static org.junit.jupiter.api.Assertions.assertEquals;
18
+
19
+/**
20
+ * The run cancel/re-run controls on the Actions run page (issue #2, phase 2): a writer can drive them
21
+ * over HTTP; a non-writer cannot.
22
+ */
23
+@QuarkusTest
24
+class ActionControlUiTest
25
+{
26
+ private static final String OWNER = "arc-owner";
27
+
28
+ private static final String STRANGER = "arc-stranger";
29
+
30
+ @Inject
31
+ GitRepositoryService repositories;
32
+
33
+ @Inject
34
+ User.Repo users;
35
+
36
+ @Inject
37
+ ActionRun.Repo runs;
38
+
39
+ @Test
40
+ @TestSecurity(user = OWNER)
41
+ void ownerCancelsRunningRun()
42
+ {
43
+ Repository repo = repoWithRun("arc-a", ActionRun.Status.RUNNING);
44
+
45
+ given().redirects().follow(false)
46
+ .when().post(base(repo) + "/1/cancel")
47
+ .then().statusCode(303);
48
+
49
+ assertEquals(ActionRun.Status.CANCELLED, runStatus(repo));
50
+ }
51
+
52
+ @Test
53
+ @TestSecurity(user = OWNER)
54
+ void ownerRerunsFinishedRun()
55
+ {
56
+ Repository repo = repoWithRun("arc-b", ActionRun.Status.FAILURE);
57
+
58
+ given().redirects().follow(false)
59
+ .when().post(base(repo) + "/1/rerun")
60
+ .then().statusCode(303);
61
+
62
+ assertEquals(ActionRun.Status.PENDING, runStatus(repo));
63
+ }
64
+
65
+ @Test
66
+ @TestSecurity(user = STRANGER)
67
+ void nonWriterCannotCancel()
68
+ {
69
+ Repository repo = repoWithRun("arc-c", ActionRun.Status.RUNNING);
70
+
71
+ given().redirects().follow(false)
72
+ .when().post(base(repo) + "/1/cancel")
73
+ .then().statusCode(403);
74
+
75
+ assertEquals(ActionRun.Status.RUNNING, runStatus(repo));
76
+ }
77
+
78
+ private static String base(Repository repo)
79
+ {
80
+ return "/repos/" + repo.ownerHandle() + "/" + repo.name + "/actions";
81
+ }
82
+
83
+ @Transactional
84
+ ActionRun.Status runStatus(Repository repo)
85
+ {
86
+ return runs.findByRepository(repo).get(0).status;
87
+ }
88
+
89
+ private Repository repoWithRun(String repoName, ActionRun.Status status)
90
+ {
91
+ User owner = persistUser(OWNER);
92
+ persistUser(STRANGER);
93
+ return createRepoWithRun(owner, repoName, status);
94
+ }
95
+
96
+ @Transactional
97
+ Repository createRepoWithRun(User owner, String repoName, ActionRun.Status status)
98
+ {
99
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
100
+ ActionRun run = new ActionRun();
101
+ run.repository = repo;
102
+ run.number = 1;
103
+ run.workflowName = "CI";
104
+ run.workflowFile = ".forgejo/workflows/ci.yml";
105
+ run.event = "push";
106
+ run.ref = "refs/heads/main";
107
+ run.commitSha = "0000000000000000000000000000000000000000";
108
+ run.status = status;
109
+ run.persist();
110
+ return repo;
111
+ }
112
+
113
+ @Transactional
114
+ User persistUser(String name)
115
+ {
116
+ return users.findByOidcSubOptional(name).orElseGet(() ->
117
+ {
118
+ User user = new User();
119
+ user.oidcSub = name;
120
+ user.username = name;
121
+ user.persist();
122
+ return user;
123
+ });
124
+ }
125
+}