✨ (ci): Show commit and MR CI status
Changes
12 files changed, +338 -24
MODIFY
README.md
+3 -2
@@ -90,8 +90,9 @@
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, jobs support `needs` ordering and `strategy.matrix`, and
94
- runs can be cancelled or re-run from the UI. Non-push events and artifacts are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
93
+ variables that are delivered to runners, jobs support `needs` ordering and `strategy.matrix`, runs
94
+ can be cancelled or re-run from the UI, and a commit's CI result shows on its commit and merge-request
95
+ pages. Non-push events, artifacts, and scoped/ephemeral runners are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
95
96
[architecture](docs/maintainers/ci-runners.md)
96
97
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
97
98
public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY
docs/maintainers/ci-runners.md
+10 -2
@@ -16,6 +16,7 @@
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
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). |
19
+| Commit status | `ci/CommitStatusService.java` | Aggregate a commit's runs into one status; shown on commit/MR pages and via the Gitea commit-status API. |
19
20
| Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
20
21
| 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`. |
21
22
| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
@@ -134,7 +135,8 @@
134
135
running run but leaves other branches alone), `MatrixExpansionTest` (single- and two-dimension
135
136
matrices expand to one task per cell with a reduced payload; a non-matrix job stays single),
136
137
`MatrixNeedsTest` (a dependent waits for every cell of a needed matrix job, and one failed cell
137
- cancels the dependent).
138
+ cancels the dependent), `CommitCiStatusTest` (commit page + MR page show the aggregate badge, the
139
+ commit-status API reflects failure, and a commit with no runs stays all-clear).
138
140
- **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
139
141
(`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
140
142
`action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
@@ -150,6 +152,12 @@
150
152
- **Superseded runs:** after ingest creates the run(s) for a push, `ActionRunService.cancelSuperseded`
151
153
cancels that branch's other still-active runs (keeping the just-created ones), so an in-flight run
152
154
is abandoned when a newer commit lands on the same ref. Other branches are unaffected.
155
+- **Commit / MR status:** `CommitStatusService.aggregate` folds a commit's runs
156
+ (`ActionRun.Repo.findByRepositoryAndCommitSha`) into one status (worst-of: FAILURE > RUNNING >
157
+ CANCELLED > SUCCESS; no runs → none). Shown as a badge on the commit-detail page and on the MR page
158
+ (for the source branch's head commit, resolved live). The Gitea `commits/{ref}/status` API now
159
+ returns the real aggregate (mapped to `success`/`failure`/`pending`) with one entry per run — a
160
+ commit with no runs still reports `success` so Renovate proceeds.
153
161
- **Actions UI:** a read-only `Actions` tab on each repository — `ActionResource` renders a run list
154
162
(workflow, run number, status, event, short commit) and a run detail page with each job and its
155
163
streamed log rows. Read-gated like the rest of the repo UI (404 for a hidden repo). Tested by
@@ -170,7 +178,7 @@
170
178
- **Matrix advanced options:** `include`/`exclude` and `fail-fast`/`max-parallel` are not honored
171
179
(plain dimension cross-product only).
172
180
- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners,
173
- commit/MR status, non-push events.
181
+ non-push events.
174
182
175
183
## References
176
184
MODIFY
docs/users/ci-runners.md
+3 -0
@@ -43,6 +43,9 @@
43
43
Pushing a new commit to a branch automatically cancels that branch's earlier still-running run, so
44
44
only the latest push keeps running.
45
45
46
+A commit's overall CI result appears as a status badge on its commit page, and a merge request shows
47
+the CI status of its source branch's latest commit.
48
+
46
49
## Trigger filters
47
50
48
51
Beyond a bare `on: push` (which runs on every branch push), you can scope runs to specific refs:
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+6 -5
@@ -213,8 +213,9 @@
213
213
}
214
214
215
215
/**
216
- * A Gitea commit status. git-shark has no status store yet, so {@code POST /statuses/{sha}} echoes the
217
- * posted status back (nothing is persisted) and the list/ combined endpoints report an empty, all-clear set.
216
+ * A Gitea commit status. The list/combined endpoints report one status per CI run on the commit;
217
+ * {@code POST /statuses/{sha}} still only echoes the posted status back (git-shark's statuses come
218
+ * from its own runs, not external posts).
218
219
*/
219
220
public record CommitStatusView(long id, String state, String context, String description,
220
221
@JsonProperty("target_url") String targetUrl)
@@ -222,9 +223,9 @@
222
223
}
223
224
224
225
/**
225
- * The Gitea combined commit status. With no status store, the combined {@code state} is reported as
226
- * {@code success} (both under the current field name and the legacy {@code worstStatus}) so a Gitea client
227
- * treats the ref as passing and proceeds; the merge endpoint still rejects a real conflict.
226
+ * The Gitea combined commit status: the worst-of the commit's CI runs (mapped to {@code success}/
227
+ * {@code failure}/{@code pending}). A commit with no runs reports {@code success} (via {@link
228
+ * #allClear}) so a Gitea client like Renovate treats the ref as passing and proceeds.
228
229
*/
229
230
public record CombinedStatusView(String state, @JsonProperty("worstStatus") String worstStatus, String sha,
230
231
@JsonProperty("total_count") int totalCount, List<CommitStatusView> statuses)
MODIFY
src/main/java/de/workaround/api/CommitStatusApiResource.java
+45 -10
@@ -2,9 +2,12 @@
2
2
3
3
import java.util.List;
4
4
5
+import de.workaround.ci.CommitStatusService;
5
6
import de.workaround.git.AccessPolicy;
6
7
import de.workaround.git.ForbiddenOperationException;
8
+import de.workaround.git.GitBrowseService;
7
9
import de.workaround.git.GitRepositoryService;
10
+import de.workaround.model.ActionRun;
8
11
import de.workaround.model.Repository;
9
12
import de.workaround.model.User;
10
13
import jakarta.inject.Inject;
@@ -19,11 +22,11 @@
19
22
import jakarta.ws.rs.core.Response;
20
23
21
24
/**
22
- * Gitea commit-status endpoints. git-shark has no status store yet (CI runners exist but are unwired), so the
23
- * combined/list reads report an empty, all-clear set and {@code POST /statuses/{sha}} echoes the posted status
24
- * without persisting it — enough for Renovate to treat a branch as passing and proceed. The {@code ref} segment
25
- * is matched greedily so slash-bearing branch names resolve. Reads follow repository visibility; posting a
26
- * status needs a token and write access.
25
+ * Gitea commit-status endpoints. The combined/list reads report the repository's CI runs for the resolved
26
+ * commit (one status per run); a commit with no runs stays all-clear {@code success} so Renovate proceeds.
27
+ * {@code POST /statuses/{sha}} still only echoes the posted status (git-shark statuses come from its own runs,
28
+ * not external posts). The {@code ref} segment is matched greedily so slash-bearing branch names resolve.
29
+ * Reads follow repository visibility; posting a status needs a token and write access.
27
30
*/
28
31
@Path("/api/v1/repos/{owner}/{name}")
29
32
@Produces(MediaType.APPLICATION_JSON)
@@ -38,13 +41,29 @@
38
41
@Inject
39
42
ApiPrincipal principal;
40
43
44
+ @Inject
45
+ GitBrowseService browse;
46
+
47
+ @Inject
48
+ CommitStatusService commitStatus;
49
+
41
50
@GET
42
51
@Path("commits/{ref:.+}/status")
43
52
public ApiModels.CombinedStatusView combined(@PathParam("owner") String owner, @PathParam("name") String name,
44
53
@PathParam("ref") String ref)
45
54
{
46
- requireReadable(owner, name);
47
- return ApiModels.CombinedStatusView.allClear(ref);
55
+ Repository repo = readable(owner, name);
56
+ String sha = resolveSha(repo, ref);
57
+ List<ActionRun> found = commitStatus.runsFor(repo, sha);
58
+ if (found.isEmpty())
59
+ {
60
+ // no runs for this commit — stay all-clear so Renovate and other clients proceed
61
+ return ApiModels.CombinedStatusView.allClear(sha);
62
+ }
63
+ String state = CommitStatusService.toGiteaState(
64
+ commitStatus.aggregate(found).orElse(ActionRun.Status.SUCCESS));
65
+ List<ApiModels.CommitStatusView> statuses = toStatusViews(found);
66
+ return new ApiModels.CombinedStatusView(state, state, sha, statuses.size(), statuses);
48
67
}
49
68
50
69
@GET
@@ -52,8 +71,23 @@
52
71
public List<ApiModels.CommitStatusView> list(@PathParam("owner") String owner, @PathParam("name") String name,
53
72
@PathParam("ref") String ref)
54
73
{
55
- requireReadable(owner, name);
56
- return List.of();
74
+ Repository repo = readable(owner, name);
75
+ return toStatusViews(commitStatus.runsFor(repo, resolveSha(repo, ref)));
76
+ }
77
+
78
+ private static List<ApiModels.CommitStatusView> toStatusViews(List<ActionRun> runs)
79
+ {
80
+ return runs.stream()
81
+ .map(run -> new ApiModels.CommitStatusView(run.number, CommitStatusService.toGiteaState(run.status),
82
+ "ci/" + run.workflowName, run.status.label, ""))
83
+ .toList();
84
+ }
85
+
86
+ private String resolveSha(Repository repo, String ref)
87
+ {
88
+ return browse.commit(repositories.repositoryPath(repo), ref)
89
+ .map(GitBrowseService.CommitInfo::id)
90
+ .orElse(ref);
57
91
}
58
92
59
93
@POST
@@ -77,12 +111,13 @@
77
111
return Response.status(Response.Status.CREATED).entity(echoed).build();
78
112
}
79
113
80
- private void requireReadable(String owner, String name)
114
+ private Repository readable(String owner, String name)
81
115
{
82
116
Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
83
117
if (!accessPolicy.canRead(principal.orNull(), repo))
84
118
{
85
119
throw new NotFoundException();
86
120
}
121
+ return repo;
87
122
}
88
123
}
ADD
src/main/java/de/workaround/ci/CommitStatusService.java
+67 -0
@@ -0,0 +1,67 @@
1
+package de.workaround.ci;
2
+
3
+import java.util.List;
4
+import java.util.Optional;
5
+
6
+import de.workaround.model.ActionRun;
7
+import de.workaround.model.Repository;
8
+import jakarta.enterprise.context.ApplicationScoped;
9
+import jakarta.inject.Inject;
10
+
11
+/**
12
+ * The overall CI status of a commit (issue #2, phase 3): the aggregate of every run for that commit
13
+ * SHA, shown on commit and merge-request pages and exposed through the Gitea commit-status API.
14
+ */
15
+@ApplicationScoped
16
+public class CommitStatusService
17
+{
18
+ @Inject
19
+ ActionRun.Repo runs;
20
+
21
+ public List<ActionRun> runsFor(Repository repository, String commitSha)
22
+ {
23
+ return runs.findByRepositoryAndCommitSha(repository, commitSha);
24
+ }
25
+
26
+ /**
27
+ * The aggregate status of a commit, or empty when it has no runs. Worst-of: any FAILURE →
28
+ * FAILURE; else anything still running/pending → RUNNING; else any CANCELLED → CANCELLED; else
29
+ * SUCCESS.
30
+ */
31
+ public Optional<ActionRun.Status> aggregate(Repository repository, String commitSha)
32
+ {
33
+ return aggregate(runsFor(repository, commitSha));
34
+ }
35
+
36
+ public Optional<ActionRun.Status> aggregate(List<ActionRun> forCommit)
37
+ {
38
+ if (forCommit.isEmpty())
39
+ {
40
+ return Optional.empty();
41
+ }
42
+ if (forCommit.stream().anyMatch(r -> r.status == ActionRun.Status.FAILURE))
43
+ {
44
+ return Optional.of(ActionRun.Status.FAILURE);
45
+ }
46
+ if (forCommit.stream().anyMatch(r -> !r.status.isTerminal()))
47
+ {
48
+ return Optional.of(ActionRun.Status.RUNNING);
49
+ }
50
+ if (forCommit.stream().anyMatch(r -> r.status == ActionRun.Status.CANCELLED))
51
+ {
52
+ return Optional.of(ActionRun.Status.CANCELLED);
53
+ }
54
+ return Optional.of(ActionRun.Status.SUCCESS);
55
+ }
56
+
57
+ /** Map an aggregate run status to a Gitea commit-status state. */
58
+ public static String toGiteaState(ActionRun.Status status)
59
+ {
60
+ return switch (status)
61
+ {
62
+ case SUCCESS -> "success";
63
+ case FAILURE, CANCELLED -> "failure";
64
+ case PENDING, RUNNING -> "pending";
65
+ };
66
+ }
67
+}
MODIFY
src/main/java/de/workaround/model/ActionRun.java
+4 -0
@@ -104,6 +104,10 @@
104
104
@HQL("select r from ActionRun r where r.repository = :repository and r.ref = :ref "
105
105
+ "and r.status in (PENDING, RUNNING)")
106
106
List<ActionRun> findActiveByRepositoryAndRef(Repository repository, String ref);
107
+
108
+ @HQL("select r from ActionRun r where r.repository = :repository and r.commitSha = :commitSha "
109
+ + "order by r.number desc")
110
+ List<ActionRun> findByRepositoryAndCommitSha(Repository repository, String commitSha);
107
111
}
108
112
109
113
}
MODIFY
src/main/java/de/workaround/web/MergeRequestResource.java
+10 -2
@@ -52,7 +52,7 @@
52
52
53
53
static native TemplateInstance mergeRequest(Repository repo, RepoNav nav, boolean owner, boolean loggedIn,
54
54
UUID currentUserId, boolean canModerate, MergeRequest mr, List<FileDiffView> files, int additions,
55
- int deletions, List<User> assignees, List<MergeRequestComment> discussion);
55
+ int deletions, List<User> assignees, List<MergeRequestComment> discussion, String ciStatus);
56
56
}
57
57
58
58
/**
@@ -79,6 +79,9 @@
79
79
GitBrowseService browse;
80
80
81
81
@Inject
82
+ de.workaround.ci.CommitStatusService commitStatusService;
83
+
84
+ @Inject
82
85
AccessPolicy accessPolicy;
83
86
84
87
@Inject
@@ -186,8 +189,13 @@
186
189
fileIndex++;
187
190
}
188
191
}
192
+ String headSha = browse.commits(service.repositoryPath(repo), mr.sourceBranch, 0, 1)
193
+ .flatMap(page -> page.commits().stream().findFirst())
194
+ .map(GitBrowseService.CommitInfo::id)
195
+ .orElse(null);
196
+ String ciStatus = headSha == null ? null : commitStatusService.aggregate(repo, headSha).map(Enum::name).orElse(null);
189
197
return Templates.mergeRequest(repo, repoNav.build(repo, uriInfo), isOwner(repo), loggedIn, currentUserId,
190
- canModerate, mr, files, additions, deletions, assignableUsers(repo), discussion);
198
+ canModerate, mr, files, additions, deletions, assignableUsers(repo), discussion, ciStatus);
191
199
}
192
200
193
201
/** How many of a repository's top commit authors the pickers offer as suggestions. */
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+6 -2
@@ -68,7 +68,7 @@
68
68
boolean hasNext);
69
69
70
70
static native TemplateInstance commit(Repository repo, RepoNav nav, GitBrowseService.CommitInfo commit,
71
- List<GitMergeService.FileDiff> files, int additions, int deletions);
71
+ List<GitMergeService.FileDiff> files, int additions, int deletions, String ciStatus);
72
72
73
73
static native TemplateInstance branches(Repository repo, RepoNav nav,
74
74
List<GitBrowseService.BranchInfo> branches);
@@ -92,6 +92,9 @@
92
92
GitMergeService mergeService;
93
93
94
94
@Inject
95
+ de.workaround.ci.CommitStatusService commitStatusService;
96
+
97
+ @Inject
95
98
AccessPolicy accessPolicy;
96
99
97
100
@Inject
@@ -256,7 +259,8 @@
256
259
Path path = service.repositoryPath(repo);
257
260
GitBrowseService.CommitInfo info = browse.commit(path, id).orElseThrow(NotFoundException::new);
258
261
GitMergeService.DiffView diff = mergeService.commitDiff(path, id).orElseThrow(NotFoundException::new);
259
- return Templates.commit(repo, nav, info, diff.files(), diff.additions(), diff.deletions());
262
+ String ciStatus = commitStatusService.aggregate(repo, info.id()).map(Enum::name).orElse(null);
263
+ return Templates.commit(repo, nav, info, diff.files(), diff.additions(), diff.deletions(), ciStatus);
260
264
}
261
265
262
266
@GET
MODIFY
src/main/resources/templates/MergeRequestResource/mergeRequest.html
+1 -0
@@ -7,6 +7,7 @@
7
7
<h2>{mr.title} <span class="issue-no">!{mr.number}</span></h2>
8
8
<p>
9
9
<span class="badge status-{mr.status}">{mr.status.label}</span>
10
+ {#if ciStatus}<span class="badge status-{ciStatus}" title="CI status of the source branch head">{ciStatus}</span>{/if}
10
11
<span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
11
12
<span class="muted">opened by {#avatar user=mr.author /} {mr.author.username}</span>
12
13
</p>
MODIFY
src/main/resources/templates/RepositoryResource/commit.html
+3 -1
@@ -5,7 +5,9 @@
5
5
<section class="repo-main">
6
6
<p><a href="/repos/{repo.ownerHandle}/{repo.name}/commits/{nav.defaultBranch}">← Commits</a></p>
7
7
<div class="commit-detail">
8
- <h2 class="commit-subject">{commit.message}</h2>
8
+ <h2 class="commit-subject">{commit.message}
9
+ {#if ciStatus}<span class="badge status-{ciStatus}" title="CI status">{ciStatus}</span>{/if}
10
+ </h2>
9
11
<p class="commit-meta muted">
10
12
<code>{commit.id}</code> · {commit.author} · {commit.date}
11
13
</p>
ADD
src/test/java/de/workaround/web/CommitCiStatusTest.java
+180 -0
@@ -0,0 +1,180 @@
1
+package de.workaround.web;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Path;
5
+import java.util.Map;
6
+import java.util.UUID;
7
+
8
+import org.junit.jupiter.api.Test;
9
+
10
+import de.workaround.git.GitBrowseService;
11
+import de.workaround.git.GitRepositoryService;
12
+import de.workaround.git.GitTestSeeder;
13
+import de.workaround.model.ActionRun;
14
+import de.workaround.model.Repository;
15
+import de.workaround.model.User;
16
+import io.quarkus.test.junit.QuarkusTest;
17
+import jakarta.inject.Inject;
18
+import jakarta.transaction.Transactional;
19
+
20
+import static io.restassured.RestAssured.given;
21
+import static org.hamcrest.Matchers.equalTo;
22
+import static org.hamcrest.Matchers.containsString;
23
+import static org.hamcrest.Matchers.not;
24
+
25
+/**
26
+ * CI status on commits (issue #2, phase 3): a commit's aggregate run status shows on its page and is
27
+ * exposed through the Gitea commit-status API; a commit with no runs stays all-clear.
28
+ */
29
+@QuarkusTest
30
+class CommitCiStatusTest
31
+{
32
+ @Inject
33
+ GitRepositoryService repositories;
34
+
35
+ @Inject
36
+ GitBrowseService browse;
37
+
38
+ @Inject
39
+ ActionRun.Repo runs;
40
+
41
+ @Inject
42
+ de.workaround.git.MergeRequestService mergeRequests;
43
+
44
+ @Inject
45
+ User.Repo users;
46
+
47
+ @Test
48
+ void commitPageShowsAggregateBadgeAndApiReflectsFailure()
49
+ {
50
+ Fixture f = seedRepoWithCommit("cs-a");
51
+ seedRun(f.repo, f.head, ActionRun.Status.FAILURE);
52
+
53
+ given().when().get("/repos/" + f.owner + "/cs-a/commit/" + f.head)
54
+ .then().statusCode(200)
55
+ .body(containsString("badge status-FAILURE"));
56
+
57
+ given().when().get("/api/v1/repos/" + f.owner + "/cs-a/commits/" + f.head + "/status")
58
+ .then().statusCode(200)
59
+ .body("state", equalTo("failure"))
60
+ .body("total_count", equalTo(1));
61
+ }
62
+
63
+ @Test
64
+ void successRunShowsSuccessBadge()
65
+ {
66
+ Fixture f = seedRepoWithCommit("cs-b");
67
+ seedRun(f.repo, f.head, ActionRun.Status.SUCCESS);
68
+
69
+ given().when().get("/repos/" + f.owner + "/cs-b/commit/" + f.head)
70
+ .then().statusCode(200)
71
+ .body(containsString("badge status-SUCCESS"));
72
+ }
73
+
74
+ @Test
75
+ void commitWithoutRunsHasNoBadgeAndApiStaysSuccess()
76
+ {
77
+ Fixture f = seedRepoWithCommit("cs-c");
78
+
79
+ given().when().get("/repos/" + f.owner + "/cs-c/commit/" + f.head)
80
+ .then().statusCode(200)
81
+ .body(not(containsString("badge status-")));
82
+
83
+ given().when().get("/api/v1/repos/" + f.owner + "/cs-c/commits/" + f.head + "/status")
84
+ .then().statusCode(200)
85
+ .body("state", equalTo("success"));
86
+ }
87
+
88
+ @Test
89
+ void mergeRequestPageShowsSourceHeadCiStatus()
90
+ {
91
+ String owner = mergeRequestWithRun("cs-mr", ActionRun.Status.FAILURE);
92
+
93
+ given().when().get("/repos/" + owner + "/cs-mr/merge-requests/1")
94
+ .then().statusCode(200)
95
+ .body(containsString("badge status-FAILURE"));
96
+ }
97
+
98
+ @Transactional
99
+ String mergeRequestWithRun(String repoName, ActionRun.Status status)
100
+ {
101
+ String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
102
+ User owner = new User();
103
+ owner.oidcSub = username;
104
+ owner.username = username;
105
+ owner.persist();
106
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
107
+ try
108
+ {
109
+ Path bare = repositories.repositoryPath(repo);
110
+ GitTestSeeder.seed(bare, Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
111
+ GitTestSeeder.seedBranch(bare, "feature",
112
+ Map.of("feature.txt", "x\n".getBytes(StandardCharsets.UTF_8)));
113
+ String head = browse.commits(bare, "feature", 0, 1).orElseThrow().commits().get(0).id();
114
+ mergeRequests.create(owner, repo, "add feature", null, "feature", "main");
115
+
116
+ ActionRun run = new ActionRun();
117
+ run.repository = repo;
118
+ run.number = runs.maxNumber(repo) + 1;
119
+ run.workflowName = "CI";
120
+ run.workflowFile = ".forgejo/workflows/ci.yml";
121
+ run.event = "push";
122
+ run.ref = "refs/heads/feature";
123
+ run.commitSha = head;
124
+ run.status = status;
125
+ run.persist();
126
+ return username;
127
+ }
128
+ catch (Exception e)
129
+ {
130
+ throw new RuntimeException(e);
131
+ }
132
+ }
133
+
134
+ private record Fixture(String owner, Repository repo, String head)
135
+ {
136
+ }
137
+
138
+ private Fixture seedRepoWithCommit(String repoName)
139
+ {
140
+ String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
141
+ Repository repo = createRepo(username, repoName);
142
+ try
143
+ {
144
+ Path bare = repositories.repositoryPath(repo);
145
+ GitTestSeeder.seed(bare, Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
146
+ String head = browse.commits(bare, "main", 0, 1).orElseThrow().commits().get(0).id();
147
+ return new Fixture(username, repo, head);
148
+ }
149
+ catch (Exception e)
150
+ {
151
+ throw new RuntimeException(e);
152
+ }
153
+ }
154
+
155
+ @Transactional
156
+ Repository createRepo(String username, String repoName)
157
+ {
158
+ User owner = new User();
159
+ owner.oidcSub = username;
160
+ owner.username = username;
161
+ owner.persist();
162
+ return repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
163
+ }
164
+
165
+ @Transactional
166
+ void seedRun(Repository repository, String sha, ActionRun.Status status)
167
+ {
168
+ Repository repo = repositories.find(repository.ownerHandle(), repository.name).orElseThrow();
169
+ ActionRun run = new ActionRun();
170
+ run.repository = repo;
171
+ run.number = runs.maxNumber(repo) + 1;
172
+ run.workflowName = "CI";
173
+ run.workflowFile = ".forgejo/workflows/ci.yml";
174
+ run.event = "push";
175
+ run.ref = "refs/heads/main";
176
+ run.commitSha = sha;
177
+ run.status = status;
178
+ run.persist();
179
+ }
180
+}