gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (ci): Add the per-repository Actions UI

afb8a11163eb867b99055058c40b3334508f06af · Michael Hainz · 2026-07-22T10:40:01Z

Changes

9 files changed, +398 -19

MODIFY README.md +8 -6
diff --git a/README.md b/README.md
index 25a94be..cdb59af 100644
--- a/README.md
+++ b/README.md
@@ -82,12 +82,14 @@
82 82 - **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
83 83 exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
84 84 activities from (see below)
85 -- **CI/CD runners** — *phase 1: registration only.* git-shark implements the server side of the
86 - Forgejo/Gitea `runner.v1` Connect protocol under `/api/actions`, so a stock `forgejo-runner` /
87 - `act_runner` registers and reports in unchanged. Instance admins (handles in
88 - `GITSHARK_ADMIN_HANDLES`) generate reusable registration tokens and manage runners at
89 - `/admin/runners`; secrets are stored hashed. Workflow execution (fetching and running jobs) is a
90 - follow-up phase. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
85 +- **CI/CD runners** — git-shark implements the server side of the Forgejo/Gitea `runner.v1` Connect
86 + protocol under `/api/actions`, so a stock `forgejo-runner` / `act_runner` registers and runs jobs
87 + unchanged. Instance admins (handles in `GITSHARK_ADMIN_HANDLES`) generate reusable registration
88 + tokens and manage runners at `/admin/runners`; secrets are stored hashed. A push adding a workflow
89 + to `.forgejo/workflows/` (plain `on: push`) creates a run, which a runner claims, executes, and
90 + streams logs for — visible on the repository's **Actions** tab; a vanished runner's task is
91 + reclaimed after a timeout. Richer triggers, secrets/variables, `needs`/`matrix`, and artifacts are
92 + follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
91 93 [architecture](docs/maintainers/ci-runners.md)
92 94 activities from; local users can in turn follow a remote repository — or a whole remote user, whose
93 95 public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY docs/maintainers/ci-runners.md +6 -2
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index bb41f9c..929beec 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -15,9 +15,10 @@
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 +| Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
18 19 | Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
19 20 | Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migrations `V23`, `V24`). `ActionTask.seq` (`bigserial`) is the surrogate int64 `Task.id` for the wire. |
20 -| Workflow ingest | `ci/WorkflowIngestService.java`, `ci/WorkflowRunFactory.java` | Post-receive hook: parse `.forgejo`/`.gitea` workflows at the pushed head, evaluate `on: push`, persist a run + its tasks. Rows are created but not yet dispatched. |
21 +| Workflow ingest | `ci/WorkflowIngestService.java`, `ci/WorkflowRunFactory.java` | Post-receive hook: parse `.forgejo`/`.gitea` workflows at the pushed head, evaluate `on: push`, persist a run + its PENDING tasks (drained by FetchTask). |
21 22 | Admin UI | `ci/AdminRunnerResource.java` + `templates/AdminRunnerResource/` | Token generation, runner list, deletion. |
22 23 | Admin gate | `account/AdminAccess.java` | Config-driven instance-admin check. |
23 24
@@ -85,6 +86,10 @@
85 86 runner OFFLINE. The deadline is set at claim time from `gitshark.ci.task-timeout` (default 1h). A
86 87 late update from a runner cannot resurrect an already-terminal task. `ZombieReclaimTest` covers both
87 88 the reclaim (overdue → FAILURE, in-deadline left RUNNING) and the anti-resurrection guard.
89 +- **Actions UI:** a read-only `Actions` tab on each repository — `ActionResource` renders a run list
90 + (workflow, run number, status, event, short commit) and a run detail page with each job and its
91 + streamed log rows. Read-gated like the rest of the repo UI (404 for a hidden repo). Tested by
92 + `ActionUiTest` (list shows runs + tab, detail shows jobs and logs, unknown run number → 404).
88 93
89 94 ## What still needs to be implemented
90 95
@@ -95,7 +100,6 @@
95 100 it needs the single job isolated/expanded. No `needs`/`matrix` yet.
96 101 - **Trigger refinement:** only bare `on: push` is honored; branch/tag/path filters and other events
97 102 (tag push, `pull_request`) are not evaluated.
98 -- **Run UI:** per-repository run list + run detail with live per-step status and logs.
99 103 - **Real-runner integration test:** protocol round-trip against an actual `forgejo-runner` container
100 104 (the current endpoint test uses a hand-built protobuf client, not the binary).
101 105 - **Later phases:** secrets/variables delivery, label-based matching, concurrency/cancellation,
MODIFY docs/users/ci-runners.md +30 -11
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index 683325a..eeb16bb 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -4,21 +4,40 @@
4 4 protocol, so the standard `forgejo-runner` (or Gitea `act_runner`) connects to it directly, and
5 5 workflows use the familiar GitHub-Actions-compatible YAML format in `.forgejo/workflows/`.
6 6
7 -> **Available today:** an **instance administrator** can register runners against this instance and
8 -> see them listed. **Running workflows is not enabled yet** — pushing a workflow file does not yet
9 -> start a job. This page will grow as workflow execution, logs, and per-repository run views land.
7 +> **Available today:** an **instance administrator** registers runners against this instance, and a
8 +> push that adds a workflow to `.forgejo/workflows/` (or `.gitea/workflows/`) starts a run that a
9 +> connected runner picks up and executes, with logs and results shown on the repository's **Actions**
10 +> tab. Triggers are limited to a plain `on: push` for now; richer triggers, secrets, and `needs`/
11 +> `matrix` arrive in later phases.
10 12
11 -## What you can do now
13 +## Running a workflow
12 14
13 -- **If you are an instance admin**, you manage runners under **Account → CI runners**. See the
14 - [admin guide](../admins/ci-runners.md) for generating a registration token and connecting a runner.
15 -- **If you are not an admin**, there is nothing to configure yet. Runner management is
16 - instance-wide and admin-only in this phase; per-repository controls and workflow authoring arrive
17 - in later phases.
15 +1. An instance admin connects a runner (see the [admin guide](../admins/ci-runners.md)).
16 +2. Add a workflow file such as `.forgejo/workflows/ci.yml` with a `push` trigger:
17 +
18 + ```yaml
19 + name: CI
20 + on: push
21 + jobs:
22 + build:
23 + runs-on: ubuntu-latest
24 + steps:
25 + - run: echo "hello from git-shark"
26 + ```
27 +3. Push it. Each push to a branch creates a run (one per workflow file whose `on:` includes `push`).
28 +
29 +## Viewing runs
30 +
31 +Open the **Actions** tab on the repository. It lists every run — newest first — with its workflow
32 +name, run number (`#1`, `#2`, …), status (Pending / Running / Success / Failure / Cancelled), the
33 +triggering event and the short commit. Click a run to see each job and its log output (as of when
34 +the page was loaded — reload to see newer lines).
35 +
36 +A run whose runner disappears mid-job is marked **Failure** once its time limit passes (configurable
37 +by the admin), so a run never hangs as Running forever.
18 38
19 39 ## What's coming
20 40
21 -- Workflows in `.forgejo/workflows/*.yml` triggered on push, with live per-step logs and results in
22 - the repository UI.
41 +- Richer triggers (branch/tag/path filters, tag pushes, merge-request events).
23 42 - Repository-level secrets and variables, `needs`/`matrix`, and run cancellation/re-run.
24 43 - Artifacts and commit/merge-request status integration.
ADD src/main/java/de/workaround/web/ActionResource.java +99 -0
diff --git a/src/main/java/de/workaround/web/ActionResource.java b/src/main/java/de/workaround/web/ActionResource.java
new file mode 100644
index 0000000..a0b5756
--- /dev/null
+++ b/src/main/java/de/workaround/web/ActionResource.java
@@ -0,0 +1,99 @@
1 +package de.workaround.web;
2 +
3 +import java.util.List;
4 +
5 +import de.workaround.account.CurrentUser;
6 +import de.workaround.git.AccessPolicy;
7 +import de.workaround.git.GitRepositoryService;
8 +import de.workaround.model.ActionLog;
9 +import de.workaround.model.ActionRun;
10 +import de.workaround.model.ActionTask;
11 +import de.workaround.model.Repository;
12 +import io.quarkus.qute.CheckedTemplate;
13 +import io.quarkus.qute.TemplateInstance;
14 +import jakarta.inject.Inject;
15 +import jakarta.ws.rs.GET;
16 +import jakarta.ws.rs.NotFoundException;
17 +import jakarta.ws.rs.PathParam;
18 +import jakarta.ws.rs.Produces;
19 +import jakarta.ws.rs.core.Context;
20 +import jakarta.ws.rs.core.MediaType;
21 +import jakarta.ws.rs.core.UriInfo;
22 +
23 +/**
24 + * Read-only Actions UI for a repository (issue #2, phase 1): a run list and a run detail page showing
25 + * each job (task) and its streamed log rows. Mirrors {@link IssueResource}'s structure — resolve the
26 + * repository, gate on read access (404 to hide private repos), and render a {@code @CheckedTemplate}.
27 + */
28 +@jakarta.ws.rs.Path("/repos/{owner}/{name}/actions")
29 +@Produces(MediaType.TEXT_HTML)
30 +public class ActionResource
31 +{
32 + @CheckedTemplate
33 + static class Templates
34 + {
35 + static native TemplateInstance runs(Repository repo, RepoNav nav, List<ActionRun> runs);
36 +
37 + static native TemplateInstance run(Repository repo, RepoNav nav, ActionRun run, List<TaskLogs> tasks);
38 + }
39 +
40 + /** A task paired with its log rows, for the run detail page. */
41 + public record TaskLogs(ActionTask task, List<ActionLog> logs)
42 + {
43 + }
44 +
45 + @Inject
46 + CurrentUser currentUser;
47 +
48 + @Inject
49 + GitRepositoryService service;
50 +
51 + @Inject
52 + AccessPolicy accessPolicy;
53 +
54 + @Inject
55 + RepoNavService repoNav;
56 +
57 + @Inject
58 + ActionRun.Repo runs;
59 +
60 + @Inject
61 + ActionTask.Repo tasks;
62 +
63 + @Inject
64 + ActionLog.Repo logs;
65 +
66 + @Context
67 + UriInfo uriInfo;
68 +
69 + @GET
70 + public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
71 + {
72 + Repository repo = requireReadable(owner, name);
73 + return Templates.runs(repo, repoNav.build(repo, uriInfo), runs.findByRepository(repo));
74 + }
75 +
76 + @GET
77 + @jakarta.ws.rs.Path("{number:\\d+}")
78 + public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
79 + @PathParam("number") int number)
80 + {
81 + Repository repo = requireReadable(owner, name);
82 + ActionRun run = runs.findByRepositoryAndNumber(repo, number).orElseThrow(NotFoundException::new);
83 + List<TaskLogs> taskLogs = tasks.findByRun(run).stream()
84 + .map(task -> new TaskLogs(task, logs.findByTask(task)))
85 + .toList();
86 + return Templates.run(repo, repoNav.build(repo, uriInfo), run, taskLogs);
87 + }
88 +
89 + private Repository requireReadable(String owner, String name)
90 + {
91 + Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
92 + if (!accessPolicy.canRead(currentUser.get(), repo))
93 + {
94 + throw new NotFoundException();
95 + }
96 + return repo;
97 + }
98 +
99 +}
MODIFY src/main/resources/META-INF/resources/shark.css +53 -0
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 2af5c0d..a7b4889 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -863,6 +863,59 @@
863 863 background: var(--success);
864 864 }
865 865
866 +/* CI run / task status */
867 +
868 +.badge.status-PENDING {
869 + color: var(--attention);
870 + border-color: var(--attention-border);
871 + background: var(--attention-bg);
872 +}
873 +
874 +.badge.status-RUNNING {
875 + color: var(--accent-deep);
876 + border-color: transparent;
877 + background: var(--accent-soft);
878 +}
879 +
880 +.badge.status-SUCCESS {
881 + color: #fff;
882 + border-color: var(--success);
883 + background: var(--success);
884 +}
885 +
886 +.badge.status-FAILURE {
887 + color: var(--danger);
888 + border-color: var(--danger-border);
889 + background: var(--danger-bg);
890 +}
891 +
892 +.badge.status-CANCELLED {
893 + color: var(--muted);
894 + border-color: var(--border);
895 +}
896 +
897 +.run-job-head {
898 + display: flex;
899 + align-items: center;
900 + gap: 10px;
901 + margin-bottom: 10px;
902 +}
903 +
904 +.run-log {
905 + margin: 0;
906 + padding: 12px 14px;
907 + overflow-x: auto;
908 + font: 12.5px/1.5 var(--mono);
909 + background: var(--code-bg, #0d1117);
910 + color: var(--code-fg, #c9d1d9);
911 + border-radius: 6px;
912 + white-space: pre;
913 +}
914 +
915 +.run-meta {
916 + font: 500 12px/1 var(--mono);
917 +}
918 +
866 919 .issues-head {
867 920 display: flex;
868 921 align-items: center;
ADD src/main/resources/templates/ActionResource/run.html +30 -0
diff --git a/src/main/resources/templates/ActionResource/run.html b/src/main/resources/templates/ActionResource/run.html
new file mode 100644
index 0000000..eee42be
--- /dev/null
+++ b/src/main/resources/templates/ActionResource/run.html
@@ -0,0 +1,30 @@
1 +{#include layout}
2 +{#title}{run.workflowName} #{run.number} – {repo.name}{/title}
3 +<div class="repo-layout">
4 + {#include RepositoryResource/sidebar nav=nav active='actions' /}
5 + <section class="repo-main">
6 + <p class="issue-back"><a href="/repos/{repo.ownerHandle}/{repo.name}/actions">← Actions</a></p>
7 + <header class="issue-header">
8 + <h2 class="issue-heading">{run.workflowName} <span class="issue-no">#{run.number}</span></h2>
9 + </header>
10 + <div class="issue-byline">
11 + <span class="badge status-{run.status}">{run.status.label}</span>
12 + <span class="run-meta muted">{run.event} · {run.ref} · {run.commitSha.substring(0, 7)}</span>
13 + </div>
14 + {#for entry in tasks}
15 + <div class="panel run-job">
16 + <div class="run-job-head">
17 + <span class="badge status-{entry.task.status}">{entry.task.status.label}</span>
18 + <span class="n">{entry.task.name}</span>
19 + </div>
20 + {#if entry.logs.isEmpty()}
21 + <p class="muted run-job-empty">No logs.</p>
22 + {#else}
23 + <pre class="run-log">{#for row in entry.logs}{row.content}
24 +{/for}</pre>
25 + {/if}
26 + </div>
27 + {/for}
28 + </section>
29 +</div>
30 +{/include}
ADD src/main/resources/templates/ActionResource/runs.html +27 -0
diff --git a/src/main/resources/templates/ActionResource/runs.html b/src/main/resources/templates/ActionResource/runs.html
new file mode 100644
index 0000000..5ef1836
--- /dev/null
+++ b/src/main/resources/templates/ActionResource/runs.html
@@ -0,0 +1,27 @@
1 +{#include layout}
2 +{#title}Actions – {repo.name}{/title}
3 +<div class="repo-layout">
4 + {#include RepositoryResource/sidebar nav=nav active='actions' /}
5 + <section class="repo-main">
6 + <div class="issues-head">
7 + <h2>Actions</h2>
8 + </div>
9 + {#if runs.isEmpty()}
10 + <p class="muted">No workflow runs yet. Push a workflow to <code>.forgejo/workflows/</code> to start one.</p>
11 + {#else}
12 + <div class="panel">
13 + {#for run in runs}
14 + <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/actions/{run.number}">
15 + <span class="fname">
16 + <span class="badge status-{run.status}">{run.status.label}</span>
17 + <span class="n">{run.workflowName}</span>
18 + <span class="issue-no">#{run.number}</span>
19 + </span>
20 + <span class="run-meta muted">{run.event} · {run.commitSha.substring(0, 7)}</span>
21 + </a>
22 + {/for}
23 + </div>
24 + {/if}
25 + </section>
26 +</div>
27 +{/include}
MODIFY src/main/resources/templates/RepositoryResource/sidebar.html +1 -0
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
index 91ff6c1..bfdba93 100644
--- a/src/main/resources/templates/RepositoryResource/sidebar.html
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -36,6 +36,7 @@
36 36 <a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
37 37 <a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
38 38 <a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
39 + <a class="{#if active == 'actions'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/actions"><span class="g">▷</span> Actions</a>
39 40 {#if nav.isOwner}
40 41 <a class="{#if active == 'settings'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/settings"><span class="g">⚙</span> Settings</a>
41 42 {/if}
ADD src/test/java/de/workaround/web/ActionUiTest.java +144 -0
diff --git a/src/test/java/de/workaround/web/ActionUiTest.java b/src/test/java/de/workaround/web/ActionUiTest.java
new file mode 100644
index 0000000..16d1586
--- /dev/null
+++ b/src/test/java/de/workaround/web/ActionUiTest.java
@@ -0,0 +1,144 @@
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.ActionLog;
9 +import de.workaround.model.ActionRun;
10 +import de.workaround.model.ActionTask;
11 +import de.workaround.model.Repository;
12 +import de.workaround.model.User;
13 +import io.quarkus.test.junit.QuarkusTest;
14 +import jakarta.inject.Inject;
15 +import jakarta.transaction.Transactional;
16 +
17 +import static io.restassured.RestAssured.given;
18 +import static org.hamcrest.Matchers.containsString;
19 +
20 +/**
21 + * The per-repository Actions UI (issue #2, phase 1): a run list and a run detail page showing the
22 + * run's tasks and their log rows, server-rendered like the issues pages.
23 + */
24 +@QuarkusTest
25 +class ActionUiTest
26 +{
27 + @Inject
28 + GitRepositoryService repositories;
29 +
30 + @Inject
31 + User.Repo users;
32 +
33 + @Inject
34 + ActionRun.Repo runs;
35 +
36 + @Inject
37 + ActionTask.Repo tasks;
38 +
39 + @Inject
40 + ActionLog.Repo logs;
41 +
42 + @Test
43 + void runListShowsRunsAndActionsTab()
44 + {
45 + Seed seed = seedRun("act-a");
46 +
47 + given().when().get("/repos/" + seed.owner + "/act-a/actions")
48 + .then().statusCode(200)
49 + .body(containsString("class=\"repo-nav\""))
50 + .body(containsString("active"))
51 + .body(containsString("CI"))
52 + .body(containsString("Success"))
53 + .body(containsString("#1"));
54 + }
55 +
56 + @Test
57 + void runDetailShowsTasksAndLogs()
58 + {
59 + Seed seed = seedRun("act-b");
60 +
61 + given().when().get("/repos/" + seed.owner + "/act-b/actions/1")
62 + .then().statusCode(200)
63 + .body(containsString("build"))
64 + .body(containsString("line 0"))
65 + .body(containsString("line 1"));
66 + }
67 +
68 + @Test
69 + void unknownRunNumberIs404()
70 + {
71 + Seed seed = seedRun("act-c");
72 +
73 + given().when().get("/repos/" + seed.owner + "/act-c/actions/999")
74 + .then().statusCode(404);
75 + }
76 +
77 + @Test
78 + void privateRepoActionsHiddenFromStrangers()
79 + {
80 + String owner = seedPrivateRepo("act-priv");
81 +
82 + given().when().get("/repos/" + owner + "/act-priv/actions")
83 + .then().statusCode(404);
84 + given().when().get("/repos/" + owner + "/act-priv/actions/1")
85 + .then().statusCode(404);
86 + }
87 +
88 + @Transactional
89 + String seedPrivateRepo(String repoName)
90 + {
91 + String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
92 + User owner = new User();
93 + owner.oidcSub = username;
94 + owner.username = username;
95 + owner.persist();
96 + repositories.create(owner, repoName, Repository.Visibility.PRIVATE, null);
97 + return username;
98 + }
99 +
100 + private record Seed(String owner)
101 + {
102 + }
103 +
104 + @Transactional
105 + Seed seedRun(String repoName)
106 + {
107 + String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
108 + User owner = new User();
109 + owner.oidcSub = username;
110 + owner.username = username;
111 + owner.persist();
112 + Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
113 +
114 + ActionRun run = new ActionRun();
115 + run.repository = repo;
116 + run.number = 1;
117 + run.workflowName = "CI";
118 + run.workflowFile = ".forgejo/workflows/ci.yml";
119 + run.event = "push";
120 + run.ref = "refs/heads/main";
121 + run.commitSha = "abcdef1234567890abcdef1234567890abcdef12";
122 + run.status = ActionRun.Status.SUCCESS;
123 + run.persist();
124 +
125 + ActionTask task = new ActionTask();
126 + task.run = run;
127 + task.name = "build";
128 + task.payload = "on: push";
129 + task.status = ActionRun.Status.SUCCESS;
130 + task.logLength = 2;
131 + task.persist();
132 +
133 + for (int i = 0; i < 2; i++)
134 + {
135 + ActionLog log = new ActionLog();
136 + log.task = task;
137 + log.lineIndex = i;
138 + log.content = "line " + i;
139 + log.persist();
140 + }
141 + return new Seed(username);
142 + }
143 +
144 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog