๐ (api): Fix Renovate-blocking gaps found in end-to-end run
Changes
11 files changed, +209 -15
MODIFY
README.md
+1 -1
@@ -16,7 +16,7 @@
16
16
[for users](docs/users/organisations.md), [for admins](docs/admins/organisations.md)
17
17
- Clone/fetch/push over `https://<host>/git/<owner>/<repo>.git`
18
18
- anonymous read on public repositories
19
- - push and private read authenticate with **personal access tokens** (HTTP Basic password)
19
+ - push and private read authenticate with **personal access tokens** (HTTP Basic username or password, like a GitHub PAT)
20
20
- Clone/fetch/push over SSH โ scp shorthand `git@<host>:<owner>/<repo>.git` on the standard
21
21
port, or `ssh://git@<host>:<port>/<owner>/<repo>.git` when the advertised port isn't 22
22
22
- public-key authentication only; keys managed per user in the UI
MODIFY
docs/README.md
+3 -0
@@ -63,6 +63,9 @@
63
63
- **[CI/CD runners](admins/ci-runners.md)** โ register Forgejo/Gitea runners via
64
64
the `runner.v1` Connect endpoints: admin handles, registration tokens, the
65
65
`/api/actions` paths, reverse-proxy notes, and the runner tables.
66
+- **[Renovate](admins/renovate.md)** โ point Renovate's `gitea` platform driver
67
+ at git-shark's Gitea-compatible `/api/v1` for automated dependency-update PRs:
68
+ token, config, and current limitations.
66
69
67
70
### For maintainers
68
71
MODIFY
docs/admins/getting-started.md
+1 -1
@@ -452,5 +452,5 @@
452
452
| SSH host key changed after redeploy | The `ssh` volume wasn't persisted โ confirm it's a named volume, not a throwaway mount. If the volume **is** there but stays empty and the logs show `Failed (AccessDeniedException) to write EC key`, the mount points are root-owned (volumes created with a pre-fix image) โ see the row below. |
453
453
| Repository creation or image/avatar upload fails; app logs show `Permission denied` or `AccessDeniedException` under `/data` | The `/data` volumes were first created by an image that didn't ship those directories, so their mount points are root-owned and the app user (UID 185, or 1001 for the native image) can't write. One-time fix: `docker compose exec --user 0 app chown -R 185:0 /data && docker compose restart app` (use `1001:0` for the native image). Details in [Persistent data](persistent-data.md#fixing-root-owned-data-volumes). |
454
454
| Profile pictures disappear after redeploy / render as broken images | The `avatars` volume wasn't mounted, so uploads landed in the container layer. Add the volume and `GITSHARK_AVATAR_ROOT` as in Step 5 โ retrofit steps in [Persistent data](persistent-data.md#upgrading-a-deployment-created-before-profile-pictures). |
455
-| `git push` over HTTP rejected | Use a personal access token (from *Access tokens*) as the Basic-auth password, not your OIDC password. |
455
+| `git push` over HTTP rejected | Use a personal access token (from *Access tokens*) as the Basic-auth password (or username โ either works, like a GitHub PAT), not your OIDC password. |
456
456
| Schema validation error at start | DB not empty / migrated by a different tool. git-shark's Flyway owns the schema; start from an empty database. |
ADD
docs/admins/renovate.md
+72 -0
@@ -0,0 +1,72 @@
1
+# Running Renovate against git-shark
2
+
3
+git-shark's `/api/v1` is [Gitea-compatible](../maintainers/gitea-api.md), so
4
+[Renovate](https://docs.renovatebot.com/) drives it with its stock `gitea`
5
+platform driver โ no git-shark-specific plugin. Renovate opens, updates, and
6
+merges dependency-update pull requests on repositories hosted here.
7
+
8
+## Prerequisites
9
+
10
+- A personal access token for the account Renovate acts as (create one under
11
+ **Settings โ Access tokens**). The account needs write access to the target
12
+ repositories (owner or collaborator).
13
+- Renovate reaches both the API host and the git host โ for a typical
14
+ deployment that is the same public origin (`https://gitshark.example.com`),
15
+ with the API under `/api/v1` and git under `/git/<owner>/<repo>.git`.
16
+
17
+## Configuration
18
+
19
+Point Renovate's `gitea` platform at the `/api/v1` endpoint. Minimal
20
+self-hosted config:
21
+
22
+```json
23
+{
24
+ "platform": "gitea",
25
+ "endpoint": "https://gitshark.example.com/api/v1",
26
+ "token": "gs_your_access_token",
27
+ "repositories": ["alice/deptest"],
28
+ "onboarding": false,
29
+ "dependencyDashboard": false,
30
+ "requireConfig": "optional"
31
+}
32
+```
33
+
34
+Or entirely by environment variable:
35
+
36
+```
37
+RENOVATE_PLATFORM=gitea
38
+RENOVATE_ENDPOINT=https://gitshark.example.com/api/v1
39
+RENOVATE_TOKEN=gs_your_access_token
40
+RENOVATE_REPOSITORIES=alice/deptest
41
+RENOVATE_ONBOARDING=false
42
+RENOVATE_DEPENDENCY_DASHBOARD=false
43
+LOG_LEVEL=debug renovate
44
+```
45
+
46
+Notes and current limitations:
47
+
48
+- **Do not set `RENOVATE_GIT_URL=endpoint`.** git-shark serves git under
49
+ `/git/<owner>/<repo>.git`, not at the API path; leave `git-url` unset so
50
+ Renovate clones from the repository's `clone_url` (which carries the correct
51
+ path). Renovate injects the token as the Basic username, which git-shark
52
+ accepts.
53
+- **`dependencyDashboard: false`** โ the dashboard needs issue open/closed
54
+ mapping and issue-comment endpoints that are not implemented yet.
55
+- **`onboarding: false`** โ pin the target repositories in `repositories`
56
+ rather than relying on an onboarding PR / autodiscovery.
57
+- **Labels and commit statuses are stubs**: labels are always empty and the
58
+ combined commit status is reported all-clear, so Renovate treats branches as
59
+ passing. There is no real CI gating yet.
60
+- Release-notes retrieval logs a warning without a `github.com` token; it does
61
+ not block PR creation.
62
+
63
+## Verifying
64
+
65
+With `LOG_LEVEL=debug`, a successful run clones the repo, extracts the
66
+dependency manifest, pushes a `renovate/<dep>-<range>` branch, and opens a pull
67
+request. Confirm via the UI (the repository's merge requests) or the API:
68
+
69
+```
70
+curl -H "Authorization: token gs_your_access_token" \
71
+ https://gitshark.example.com/api/v1/repos/alice/deptest/pulls?state=open
72
+```
MODIFY
docs/maintainers/gitea-api.md
+24 -4
@@ -23,7 +23,8 @@
23
23
24
24
| Concern | Type | Notes |
25
25
|---|---|---|
26
-| Auth | `ApiTokenAuthFilter` | Accepts both `Authorization: Bearer <PAT>` and the Gitea-style `Authorization: token <PAT>`; same personal access tokens as git-over-HTTP |
26
+| Auth (REST) | `ApiTokenAuthFilter` | Accepts both `Authorization: Bearer <PAT>` and the Gitea-style `Authorization: token <PAT>`; same personal access tokens as git-over-HTTP |
27
+| Auth (git) | `GitBasicAuthFilter` | Basic auth for git-over-HTTP: the PAT is accepted as the **password or the username** (Renovate clones with the token in the username, empty password โ like a GitHub PAT) |
27
28
| DTOs | `ApiModels` | All response records are Gitea-shaped; snake_case fields via `@JsonProperty`. **Shared with the MCP tools** โ reshaping the REST body reshapes MCP tool output too (accepted; see decisions) |
28
29
| Surrogate ids | `GiteaIds` | Folds a `UUID` PK into a stable non-negative `long` for Gitea's int64 `id` |
29
30
| Version probe | `VersionApiResource` | `GET /api/v1/version`; string from `gitshark.gitea-api.version` |
@@ -52,7 +53,12 @@
52
53
- **Fields git-shark has no feature for are hard-coded:** `archived` and `mirror`
53
54
are always false (no archive feature; push-mirrors are outbound, not incoming
54
55
mirrors), and the `allow_*` merge flags advertise merge commits only, matching
55
- the one merge strategy the merge service implements.
56
+ the one merge strategy the merge service implements. `default_merge_style` is
57
+ reported as `merge` and is **load-bearing, not cosmetic**: Renovate picks a
58
+ merge method by running `default_merge_style` first through a `.find`, and its
59
+ `isAllowed` *throws* on an unrecognized style โ so omitting the field (leaving
60
+ it undefined on the wire) makes Renovate block the whole repository with
61
+ "unknown merge style" before it ever checks `allow_merge_commits`.
56
62
- **`mergeable` is a placeholder** = "the pull is open", not a real conflict
57
63
check. Computing true mergeability needs a live trial-merge per pull; Renovate
58
64
only needs a hint, and the actual `POST {number}/merge` still rejects a real
@@ -98,5 +104,19 @@
98
104
priority).
99
105
- Issue open/closed mapping + issue-comment REST endpoints (dependency dashboard);
100
106
deferred โ run Renovate with `dependencyDashboard: false`.
101
-- A real Renovate `LOG_LEVEL=debug` end-to-end run to validate JSON fidelity
102
- (field-name mismatches fail silently).
107
+
108
+## Validation
109
+
110
+A real Renovate `LOG_LEVEL=debug` run (`platform: gitea`, `endpoint:
111
+http://localhost:8080/api/v1`, `git-url` unset so it clones via `clone_url`)
112
+against a seeded repo with an outdated npm dependency **opened dependency PRs
113
+end to end**, and merging one via `POST /pulls/{number}/merge` advanced `main`.
114
+Two field-fidelity bugs surfaced and were fixed as part of this:
115
+
116
+- **`default_merge_style` must be present** (see decisions) โ Renovate blocked
117
+ the whole repo without it.
118
+- **git auth must accept the token as the Basic username** โ Renovate clones
119
+ with the PAT in the username and an empty password.
120
+
121
+The npm registry lookup for the dependency needs outbound network; Release-notes
122
+retrieval warns without a `github.com` token but does not block PR creation.
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+3 -3
@@ -51,7 +51,7 @@
51
51
@JsonProperty("clone_url") String cloneUrl, @JsonProperty("html_url") String htmlUrl, RepositoryView parent,
52
52
PermissionsView permissions, @JsonProperty("allow_merge_commits") boolean allowMergeCommits,
53
53
@JsonProperty("allow_rebase") boolean allowRebase, @JsonProperty("allow_squash_merge") boolean allowSquashMerge,
54
- Instant createdAt)
54
+ @JsonProperty("default_merge_style") String defaultMergeStyle, Instant createdAt)
55
55
{
56
56
/**
57
57
* A shallow projection used only as a fork's {@code parent}: DB-derived fields only, no git read or URLs.
@@ -61,7 +61,7 @@
61
61
{
62
62
return new RepositoryView(GiteaIds.of(repo.id), repo.name, repo.ownerHandle() + "/" + repo.name,
63
63
OwnerView.of(repo), repo.description, repo.visibility == Repository.Visibility.PRIVATE,
64
- repo.parent != null, false, false, false, null, null, null, null, null, true, false, false,
64
+ repo.parent != null, false, false, false, null, null, null, null, null, true, false, false, "merge",
65
65
repo.createdAt);
66
66
}
67
67
@@ -76,7 +76,7 @@
76
76
RepositoryView parent = showParent && fork ? shallow(repo.parent) : null;
77
77
return new RepositoryView(GiteaIds.of(repo.id), repo.name, repo.ownerHandle() + "/" + repo.name,
78
78
OwnerView.of(repo), repo.description, repo.visibility == Repository.Visibility.PRIVATE, fork, false,
79
- false, empty, defaultBranch, cloneUrl, htmlUrl, parent, permissions, true, false, false,
79
+ false, empty, defaultBranch, cloneUrl, htmlUrl, parent, permissions, true, false, false, "merge",
80
80
repo.createdAt);
81
81
}
82
82
}
MODIFY
src/main/java/de/workaround/dev/DevDataSeeder.java
+36 -0
@@ -11,6 +11,8 @@
11
11
import de.workaround.git.IssueService;
12
12
import de.workaround.git.MergeRequestCommentService;
13
13
import de.workaround.git.MergeRequestService;
14
+import de.workaround.http.AccessTokenService;
15
+import de.workaround.model.AccessToken;
14
16
import de.workaround.model.Issue;
15
17
import de.workaround.model.MergeRequest;
16
18
import de.workaround.model.Repository;
@@ -36,6 +38,13 @@
36
38
private static final String DEMO_REPO = "demo";
37
39
private static final String DEMO_BRANCH = "feature";
38
40
41
+ /**
42
+ * A fixed personal access token seeded for the demo user so local API/Renovate testing needs no UI login.
43
+ * DEV ONLY: seeded exclusively under the {@code %dev} profile (guarded by {@code gitshark.dev.seed-data},
44
+ * true only in {@code %dev}), so it never exists in a production database.
45
+ */
46
+ public static final String DEV_ACCESS_TOKEN = "gs_dev-only-local-renovate-token-0123456789";
47
+
39
48
@Inject
40
49
GitRepositoryService repositories;
41
50
@@ -54,6 +63,12 @@
54
63
@Inject
55
64
IssueService issueService;
56
65
66
+ @Inject
67
+ AccessTokenService accessTokens;
68
+
69
+ @Inject
70
+ AccessToken.Repo accessTokenRepo;
71
+
57
72
@ConfigProperty(name = "gitshark.dev.seed-data", defaultValue = "false")
58
73
boolean enabled;
59
74
@@ -98,6 +113,27 @@
98
113
99
114
seedMergeRequest(alice, demo, bare);
100
115
seedIssues(alice, demo);
116
+ seedDevAccessToken(alice);
117
+ }
118
+
119
+ /**
120
+ * Seeds the fixed {@link #DEV_ACCESS_TOKEN} for the given user so local API and Renovate testing needs no
121
+ * UI login. Idempotent: skipped when the user already has any token. DEV ONLY โ see {@link #DEV_ACCESS_TOKEN}.
122
+ */
123
+ @Transactional
124
+ public void seedDevAccessToken(User user)
125
+ {
126
+ if (!accessTokenRepo.findByUser(user).isEmpty())
127
+ {
128
+ return;
129
+ }
130
+ AccessToken token = new AccessToken();
131
+ token.user = user;
132
+ token.label = "dev (local API/Renovate testing)";
133
+ token.tokenHash = accessTokens.hash(DEV_ACCESS_TOKEN);
134
+ token.persist();
135
+ LOG.warnf("Seeded DEV access token for %s: %s (DEV ONLY โ never enable gitshark.dev.seed-data in production)",
136
+ user.username, DEV_ACCESS_TOKEN);
101
137
}
102
138
103
139
/**
MODIFY
src/main/java/de/workaround/http/GitBasicAuthFilter.java
+34 -6
@@ -2,7 +2,9 @@
2
2
3
3
import java.io.IOException;
4
4
import java.nio.charset.StandardCharsets;
5
+import java.util.ArrayList;
5
6
import java.util.Base64;
7
+import java.util.List;
6
8
import java.util.Optional;
7
9
8
10
import de.workaround.model.User;
@@ -16,8 +18,9 @@
16
18
import jakarta.servlet.http.HttpServletResponseWrapper;
17
19
18
20
/**
19
- * HTTP Basic authentication for Git smart HTTP. The Basic password is a personal access
20
- * token; the username part is informational only (like GitHub PATs). Requests without
21
+ * HTTP Basic authentication for Git smart HTTP. The personal access token may be supplied as the
22
+ * Basic password (the git default, with any username) or as the Basic username (as Renovate and
23
+ * other Gitea clients send it) โ either position is accepted, like a GitHub PAT. Requests without
21
24
* credentials continue anonymously โ the GitServlet decides whether anonymous is enough.
22
25
* Invalid credentials are rejected immediately. All 401 responses carry a Basic challenge
23
26
* so Git clients prompt for credentials and retry.
@@ -37,7 +40,7 @@
37
40
String header = request.getHeader("Authorization");
38
41
if (header != null && header.regionMatches(true, 0, "Basic ", 0, 6))
39
42
{
40
- Optional<User> user = decodePassword(header).flatMap(tokenService::authenticate);
43
+ Optional<User> user = authenticate(header);
41
44
if (user.isEmpty())
42
45
{
43
46
response.setHeader("WWW-Authenticate", CHALLENGE);
@@ -49,18 +52,43 @@
49
52
chain.doFilter(request, new ChallengeOn401(response));
50
53
}
51
54
52
- private static Optional<String> decodePassword(String header)
55
+ /** Tries the token in both Basic positions โ password first (git default), then username. */
56
+ private Optional<User> authenticate(String header)
57
+ {
58
+ for (String candidate : credentialCandidates(header))
59
+ {
60
+ Optional<User> user = tokenService.authenticate(candidate);
61
+ if (user.isPresent())
62
+ {
63
+ return user;
64
+ }
65
+ }
66
+ return Optional.empty();
67
+ }
68
+
69
+ private static List<String> credentialCandidates(String header)
53
70
{
54
71
try
55
72
{
56
73
String decoded = new String(Base64.getDecoder().decode(header.substring(6).trim()),
57
74
StandardCharsets.UTF_8);
58
75
int colon = decoded.indexOf(':');
59
- return colon >= 0 ? Optional.of(decoded.substring(colon + 1)) : Optional.empty();
76
+ String username = colon >= 0 ? decoded.substring(0, colon) : decoded;
77
+ String password = colon >= 0 ? decoded.substring(colon + 1) : "";
78
+ List<String> candidates = new ArrayList<>(2);
79
+ if (!password.isEmpty())
80
+ {
81
+ candidates.add(password);
82
+ }
83
+ if (!username.isEmpty())
84
+ {
85
+ candidates.add(username);
86
+ }
87
+ return candidates;
60
88
}
61
89
catch (IllegalArgumentException e)
62
90
{
63
- return Optional.empty();
91
+ return List.of();
64
92
}
65
93
}
66
94
MODIFY
src/test/java/de/workaround/api/RepositoryApiTest.java
+1 -0
@@ -47,6 +47,7 @@
47
47
.body("private", is(false))
48
48
.body("description", equalTo("gadgets"))
49
49
.body("default_branch", equalTo("main"))
50
+ .body("default_merge_style", equalTo("merge"))
50
51
.body("clone_url", org.hamcrest.Matchers.endsWith("/git/" + owner.username + "/widgets.git"))
51
52
.body("permissions.admin", is(true))
52
53
.extract().header("Location");
MODIFY
src/test/java/de/workaround/dev/DevDataSeederTest.java
+16 -0
@@ -10,9 +10,11 @@
10
10
import de.workaround.git.GitRepositoryService;
11
11
import de.workaround.git.IssueService;
12
12
import de.workaround.git.MergeRequestCommentService;
13
+import de.workaround.http.AccessTokenService;
13
14
import de.workaround.model.Issue;
14
15
import de.workaround.model.MergeRequest;
15
16
import de.workaround.model.Repository;
17
+import de.workaround.model.User;
16
18
import io.quarkus.test.junit.QuarkusTest;
17
19
import jakarta.inject.Inject;
18
20
@@ -41,6 +43,20 @@
41
43
@Inject
42
44
IssueService issues;
43
45
46
+ @Inject
47
+ AccessTokenService tokens;
48
+
49
+ @Test
50
+ void seedsAFixedDevAccessTokenForAliceIdempotently()
51
+ {
52
+ seeder.seed();
53
+ seeder.seed();
54
+
55
+ User authenticated = tokens.authenticate(DevDataSeeder.DEV_ACCESS_TOKEN).orElse(null);
56
+ assertTrue(authenticated != null, "the seeded dev token must authenticate");
57
+ assertEquals("alice", authenticated.username);
58
+ }
59
+
44
60
@Test
45
61
void seedsAliceWithDemoRepoContainingACommit()
46
62
{
MODIFY
src/test/java/de/workaround/http/GitSmartHttpTest.java
+18 -0
@@ -174,6 +174,24 @@
174
174
}
175
175
176
176
@Test
177
+ void tokenSuppliedAsUsernameAuthenticates() throws Exception
178
+ {
179
+ // Renovate (and Gitea clients) put the PAT in the Basic username with an empty password;
180
+ // git-shark must accept the token in either position, like a GitHub PAT.
181
+ User owner = persistUser();
182
+ Repository repo = createRepo(owner, "token-as-username", Repository.Visibility.PRIVATE);
183
+ seedCommit(service.repositoryPath(repo));
184
+
185
+ String token = createToken(owner);
186
+ Path target = Files.createTempDirectory("token-username");
187
+ try (Git clone = cloneOver(httpUrl(owner, "token-as-username"), target,
188
+ new UsernamePasswordCredentialsProvider(token, "")))
189
+ {
190
+ assertTrue(Files.exists(target.resolve("README.md")));
191
+ }
192
+ }
193
+
194
+ @Test
177
195
void revokedTokenIsRejected() throws Exception
178
196
{
179
197
User owner = persistUser();