feat(engine): a forge-neutral failure type and listers for Gitea servers
The engine can list the organizations and repositories of a Gitea server (or one that speaks its API) beside GitHub. Library only: accounts, the CLI and the app do not use it yet (#201). - ForgeKind, and ForgeAccessException with ForgeAccessKind: what a failed request to any forge means to a caller. GitHubAccessException derives from it and keeps its own Kind, so code written against it still compiles and still catches. - GiteaServerUrl: the address of a server is HTTPS, without credentials, and without anything after its path. There is no setting for http. - GiteaRepositoryLister and GiteaOrganizationLister, on HttpClient and source-generated JSON, with no new package. The token goes to the server the lister was made for and nowhere else: redirects are not followed, the address of a next page is never taken from an answer, and clone addresses are built from the configured address instead of being read from the server. Certificates are validated by the platform; nothing switches that off. Answers, lists and requests are bounded. - Paging follows what the server says about a next page: a Gitea server caps the page size and leaves out what the token may not see, so a short or empty page does not end a list. - A lister contract test suite that the listers of both forges pass. - ADR 0010; threat model T16. Closes #202 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
e9f8377b13
commit
0d76ea6a95
31 files changed
+2328
-68
No files matched your search
@@ -0,0 +1,94 @@
|
||||
# 0010. Forges beside GitHub: Gitea servers at an address the user names
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-11-11
|
||||
- Issues: #201 (the feature), #202 (the engine, this decision's first use)
|
||||
|
||||
## Context
|
||||
|
||||
gclo lists and clones the repositories of a GitHub organization or account. The
|
||||
project itself now lives on a Gitea server, and so do the repositories of people
|
||||
who want to mirror theirs. Gitea (and Forgejo, which speaks the same API) differs
|
||||
from GitHub in one way that matters for security: there is no one well-known host.
|
||||
The server is at an address a user types. That address decides where the account's
|
||||
token is sent.
|
||||
|
||||
The engine already had the right seams: `IRepositoryLister`, `IOrganizationLister`
|
||||
and `IGitClient` say nothing about GitHub, and since #139 the git client gives the
|
||||
token only to the host of the clone address it was handed. What was GitHub-specific
|
||||
were the two lister implementations and the failure type they throw.
|
||||
|
||||
## Decision
|
||||
|
||||
- **A forge is a pair of listers.** `GiteaRepositoryLister` and
|
||||
`GiteaOrganizationLister` implement the existing interfaces and are constructed
|
||||
with the server's address. `ForgeKind` names the kinds (`GitHub`, `Gitea`). The
|
||||
sync engine, the path recovery and the git client are not changed.
|
||||
- **One failure type.** `ForgeAccessException` with `ForgeAccessKind` is what a
|
||||
failed request to any forge is translated into. `GitHubAccessException` now
|
||||
derives from it and keeps its own `Kind`, so code written against it still
|
||||
compiles and still catches.
|
||||
- **No new dependency.** The Gitea API is called with `HttpClient` and read with
|
||||
source-generated `System.Text.Json`. Both are tested offline through a fake
|
||||
message handler, so none of it is excluded from the coverage gate.
|
||||
- **The address of a server** (`GiteaServerUrl`) is HTTPS, has no user name or
|
||||
password in it, and nothing after its path. A host typed without a scheme is
|
||||
taken as HTTPS. `http://` is refused, and there is no setting that allows it.
|
||||
- **Certificates are validated by the platform, and that cannot be switched off.**
|
||||
A server with a certificate from a private authority works once Windows trusts
|
||||
that authority.
|
||||
- **The token goes to the account's server and nowhere else.**
|
||||
- Every API request is made to the configured address, with the token in the
|
||||
`Authorization` header. Redirects are not followed; a redirect is reported as
|
||||
"this is not the server's own address".
|
||||
- The address of a next page is never taken from the server (`Link` headers are
|
||||
read only for "is there a next page").
|
||||
- Clone addresses are built from the configured address, the owner and the
|
||||
repository name. The `clone_url` in the server's answers is not read. So the
|
||||
host that gets the token for a clone is the host the account names, whatever
|
||||
the server's own idea of its address is.
|
||||
- **What a server sends is bounded**: the size of an answer, the number of pages of
|
||||
a list, and the time of a request. A server's own words reach a message only as
|
||||
the reason of a refusal, on one line and cut to a length.
|
||||
- **Paging follows what the server says**, not the size of a page: a Gitea server
|
||||
caps the page size, and leaves out of a page what the token may not see, so a
|
||||
short or empty page does not end a list. Where a server sends no `Link` header,
|
||||
an empty page does.
|
||||
- **An account that is not an organization** is listed as on GitHub: the token's
|
||||
own account gets its own repositories, another account the ones the token can see
|
||||
there. From the list of the token's own repositories only those the account owns
|
||||
are taken; that list also holds repositories of others the token may work on.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A Gitea client package.** One more dependency to track and to trust with the
|
||||
token, for six endpoints.
|
||||
- **Using `clone_url` from the answers.** It would follow a server whose configured
|
||||
root address differs from the one the user reaches it by (a reverse proxy), but
|
||||
it lets the server name the host that receives the token for git. Building the
|
||||
address also makes such a server work.
|
||||
- **Following redirects on the same host.** Simpler to refuse them all than to
|
||||
decide which are the same host.
|
||||
- **Allowing `http://` for a server on a local network, or a switch to accept any
|
||||
certificate.** Both put the token on the wire, or in the hands of whoever answers,
|
||||
in exactly the setups where nobody is watching. The supported route for a private
|
||||
authority is to trust it in Windows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A server reachable only through a sign-in page or a single-sign-on proxy does not
|
||||
work: the answer is a page, not the API, and gclo says that the address does not
|
||||
answer like a Gitea server.
|
||||
- A token needs to be allowed to read repositories, organizations and the user
|
||||
(`read:repository`, `read:organization`, `read:user`). Without the second, the
|
||||
organization list holds the personal account only and an organization is typed
|
||||
by hand, as on GitHub.
|
||||
- The git client uses the same credential form as for GitHub (a fixed user name,
|
||||
the token as the password). Gitea accepts a token as the password with any user
|
||||
name; this is to be confirmed against a live server before accounts can use a
|
||||
Gitea server (#201, step 2).
|
||||
- The engine's public surface grows (`ForgeKind`, `ForgeAccessException`,
|
||||
`ForgeAccessKind`, `GiteaServerUrl`, the two listers), and the base type of
|
||||
`GitHubAccessException` changes: a minor version for the package.
|
||||
- Accounts, the command line and the desktop app follow in their own changes
|
||||
(#201). Until then the app and the CLI behave as before.
|
||||
@@ -27,6 +27,7 @@ about the user's accounts or repositories. It sends nothing anywhere else.
|
||||
| gclo app / CLI | `gclo.exe` (WinUI 3) and the CLI `gclo.exe`, same engine | Trusted; runs as the user |
|
||||
| GitHub REST API | Lists organizations and repositories | Untrusted input (responses are parsed), authenticated by the PAT |
|
||||
| Git remotes (`github.com`) | Clone and fetch over HTTPS via libgit2 | Untrusted input (repository content, including file paths) |
|
||||
| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). At this revision in the engine library only: accounts, the CLI and the app do not use it yet (#201) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes |
|
||||
| Windows Credential Manager | Stores saved-account and default tokens | Trusted store; boundary is the Windows user account |
|
||||
| Local data directory | `%LOCALAPPDATA%\KofTwentyTwo\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs. Deliberately outside the Velopack install root `%LOCALAPPDATA%\gclo`, which Setup.exe deletes on reinstall (#91) | Trusted; same user |
|
||||
| Target folder | Where repositories are written; may already contain repositories | Partly untrusted (pre-existing content, `.git` config) |
|
||||
@@ -99,6 +100,7 @@ flowchart LR
|
||||
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
|
||||
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
|
||||
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
|
||||
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages, requests in time. A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
|
||||
| T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) |
|
||||
|
||||
## 7. Accepted risks
|
||||
@@ -130,3 +132,4 @@ flowchart LR
|
||||
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Update feed and release downloads moved from GitHub Releases to the project's releases on `git.kof22.com` (ADR 0009, #170): the in-app updater reads the feed with Velopack's Gitea source, anonymously over HTTPS, and the Scoop and Chocolatey manifests download from there. The updater verifies packages against the hashes in the feed, as before; it has never checked a signature or an attestation. The trust that was placed in GitHub's release hosting (account security, immutable releases) is now placed in a self-hosted server: whoever can publish a release there, or holds its TLS certificate or DNS, can deliver an update that installs without a warning. T1 is rewritten to say so and its status changed from mitigated to accepted. The manifests still pin a SHA-256 (T2). |
|
||||
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Corrections from the release reviews (#183): the header names the version reviewed; section 1 says that an installed build contacts `git.kof22.com` for updates; the accepted risks no longer say the binaries are unsigned (they are signed, ADR 0008) and name T1 and the Scoop part of T2 instead; T2 and T11 say what ADR 0009 changed (the Scoop manifest and the file it names come from one server; the copy of release assets to that server is manual and outside the attested pipeline). |
|
||||
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T4: a proxy named in a folder's own git configuration, and its own `http.followRedirects`, are refused like a URL rewrite, so the folder does not choose the server a fetch is made through or sent on to (the token was not exposed by the proxy: TLS is end to end). T3: a repository whose name is git's own folder name, in any spelling Windows opens as that folder, fails its row in the sync on every platform, not only on Windows, and is refused by the WSL clone (#184). |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 added with the Gitea listers of the engine (ADR 0010, #202): a server at an address the user names is a new component and a new place a token is sent to. The rules that keep the token on that server are stated there. Accounts, the CLI and the app do not use a Gitea server yet. |
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A Gitea server for the offline suite: answers the requests of <see cref="GiteaApi"/>
|
||||
/// from a table of paths, and records what it was asked and with which token.
|
||||
/// Anything not in the table is answered the way Gitea answers an unknown path: 404
|
||||
/// with a JSON reason.
|
||||
/// </summary>
|
||||
internal sealed class FakeGiteaServer : HttpMessageHandler
|
||||
{
|
||||
/// <summary>The address accounts of this server are given.</summary>
|
||||
public static readonly Uri Address = new("https://git.example.test/");
|
||||
|
||||
private readonly ConcurrentQueue<(Uri Address, string? Authorization)> _requests = new();
|
||||
|
||||
private readonly ConcurrentDictionary<string, Func<HttpResponseMessage>> _answers = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>Called for every request before it is answered; throwing here is a request that fails on the way.</summary>
|
||||
public Func<HttpRequestMessage, CancellationToken, Task>? OnRequest { get; set; }
|
||||
|
||||
/// <summary>What a path that is not in the table is answered with; Gitea's own 404 when null.</summary>
|
||||
public Func<HttpResponseMessage>? Otherwise { get; set; }
|
||||
|
||||
/// <summary>Every request so far: its address and its Authorization header.</summary>
|
||||
public IReadOnlyList<(Uri Address, string? Authorization)> Requests => [.. _requests];
|
||||
|
||||
/// <summary>The paths (with query, after <c>/api/v1/</c>) asked for so far.</summary>
|
||||
public IReadOnlyList<string> Asked => [.. _requests.Select(r => r.Address.PathAndQuery["/api/v1/".Length..])];
|
||||
|
||||
|
||||
|
||||
/// <summary>A client of this server at <see cref="Address"/>.</summary>
|
||||
public GiteaApi Api(TimeSpan? requestTimeout = null) => new(Address, new HttpClient(this), requestTimeout);
|
||||
|
||||
|
||||
|
||||
/// <summary>Answers <paramref name="path"/> (after <c>/api/v1/</c>, with its query) with what <paramref name="answer"/> makes.</summary>
|
||||
public FakeGiteaServer On(string path, Func<HttpResponseMessage> answer)
|
||||
{
|
||||
_answers[path] = answer;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Answers page <paramref name="page"/> of the list at <paramref name="path"/> with <paramref name="items"/>.</summary>
|
||||
/// <param name="path">The list's path, without query.</param>
|
||||
/// <param name="page">The page, from 1.</param>
|
||||
/// <param name="items">The JSON of each item.</param>
|
||||
/// <param name="more">What the Link header says about a next page; null for no Link header.</param>
|
||||
public FakeGiteaServer OnPage(string path, int page, IEnumerable<string> items, bool? more = null)
|
||||
=> On($"{path}?page={page}&limit={GiteaApi.PageSize}", () => Json($"[{string.Join(',', items)}]", link: more));
|
||||
|
||||
|
||||
|
||||
/// <summary>A JSON answer.</summary>
|
||||
public static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK, bool? link = null)
|
||||
{
|
||||
var response = new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||
if(link is bool more)
|
||||
{
|
||||
response.Headers.TryAddWithoutValidation(
|
||||
"Link",
|
||||
more
|
||||
? "<https://elsewhere.example.test/api/v1/x?page=2>; rel=\"next\",<https://elsewhere.example.test/api/v1/x?page=9>; rel=\"last\""
|
||||
: "<https://elsewhere.example.test/api/v1/x?page=1>; rel=\"first\"");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The answer of a refused request, as Gitea gives it.</summary>
|
||||
public static HttpResponseMessage Refusal(HttpStatusCode status, string message)
|
||||
=> Json($$"""{"message":"{{message}}","url":"https://git.example.test/api/swagger"}""", status);
|
||||
|
||||
|
||||
|
||||
/// <summary>A repository as the API describes it.</summary>
|
||||
public static string Repo(
|
||||
string name, string? owner = "acme", string? branch = "main", bool archived = false, bool empty = false)
|
||||
{
|
||||
string ownerJson = owner is null ? "null" : $$"""{"login":"{{owner}}","id":7}""";
|
||||
string branchJson = branch is null ? "null" : $"\"{branch}\"";
|
||||
return $$"""
|
||||
{"id":1,"name":"{{name}}","full_name":"{{owner}}/{{name}}","owner":{{ownerJson}},
|
||||
"clone_url":"https://attacker.example.test/{{owner}}/{{name}}.git","default_branch":{{branchJson}},
|
||||
"archived":{{(archived ? "true" : "false")}},"empty":{{(empty ? "true" : "false")}},"mirror":false}
|
||||
""";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
_requests.Enqueue((request.RequestUri!, request.Headers.Authorization?.ToString()));
|
||||
if(OnRequest is { } onRequest)
|
||||
{
|
||||
await onRequest(request, cancellationToken);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
string path = request.RequestUri!.PathAndQuery;
|
||||
string key = path.StartsWith("/api/v1/", StringComparison.Ordinal) ? path["/api/v1/".Length..] : path;
|
||||
return _answers.TryGetValue(key, out Func<HttpResponseMessage>? answer)
|
||||
? answer()
|
||||
: Otherwise?.Invoke() ?? Refusal(HttpStatusCode.NotFound, "Not Found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One failure type for every forge: code that handles <see cref="ForgeAccessException"/>
|
||||
/// handles the GitHub listers' failures too, and code written against
|
||||
/// <see cref="GitHubAccessException"/> before there was a second forge keeps working.
|
||||
/// </summary>
|
||||
public sealed class ForgeAccessExceptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void TheKindsOfBothTypes_AreTheSame_NameForNameAndValueForValue()
|
||||
{
|
||||
Assert.Equal(Enum.GetNames<GitHubAccessKind>(), Enum.GetNames<ForgeAccessKind>(), StringComparer.Ordinal);
|
||||
Assert.Equal(
|
||||
Enum.GetValues<GitHubAccessKind>().Select(kind => (int)kind),
|
||||
Enum.GetValues<ForgeAccessKind>().Select(kind => (int)kind));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(GitHubAccessKind.Unauthorized, ForgeAccessKind.Unauthorized)]
|
||||
[InlineData(GitHubAccessKind.Forbidden, ForgeAccessKind.Forbidden)]
|
||||
[InlineData(GitHubAccessKind.RateLimited, ForgeAccessKind.RateLimited)]
|
||||
[InlineData(GitHubAccessKind.NotFound, ForgeAccessKind.NotFound)]
|
||||
[InlineData(GitHubAccessKind.Unavailable, ForgeAccessKind.Unavailable)]
|
||||
public void AGitHubFailure_IsAForgeFailureOfTheSameKind(GitHubAccessKind gitHub, ForgeAccessKind forge)
|
||||
{
|
||||
var cause = new TimeoutException("cause");
|
||||
var failure = new GitHubAccessException(gitHub, "what happened", cause);
|
||||
|
||||
ForgeAccessException asForge = failure;
|
||||
InvalidOperationException asBefore = failure;
|
||||
|
||||
Assert.Equal(gitHub, failure.Kind);
|
||||
Assert.Equal(forge, asForge.Kind);
|
||||
Assert.Equal("what happened", asBefore.Message);
|
||||
Assert.Same(cause, asBefore.InnerException);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AForgeFailure_CarriesItsKind_ItsMessage_AndItsCause()
|
||||
{
|
||||
var cause = new TimeoutException("cause");
|
||||
|
||||
var failure = new ForgeAccessException(ForgeAccessKind.RateLimited, "slow down", cause);
|
||||
|
||||
Assert.Equal(ForgeAccessKind.RateLimited, failure.Kind);
|
||||
Assert.Equal("slow down", failure.Message);
|
||||
Assert.Same(cause, failure.InnerException);
|
||||
Assert.Null(new ForgeAccessException(ForgeAccessKind.NotFound, "nothing there").InnerException);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using Octokit;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>The lister contract, kept by the GitHub listers.</summary>
|
||||
public sealed class GitHubListerContractTests : ListerContractTests
|
||||
{
|
||||
private readonly GitHubListerTests.FakeGateway _gateway = new();
|
||||
|
||||
private int _requests;
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IRepositoryLister Repositories => new GitHubRepositoryLister(_ => _gateway);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IOrganizationLister Organizations => new GitHubOrganizationLister(_ => _gateway);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override int RequestsMade => _requests;
|
||||
|
||||
|
||||
|
||||
public GitHubListerContractTests()
|
||||
{
|
||||
// Until a test says otherwise: no organization is known, and every request is counted.
|
||||
_gateway.OrgPages = (_, _) => Asked<IReadOnlyList<GitHubRepo>>(() => throw NotFound());
|
||||
_gateway.OwnPages = _ => Asked<IReadOnlyList<GitHubRepo>>(() => []);
|
||||
_gateway.UserPages = (_, _) => Asked<IReadOnlyList<GitHubRepo>>(() => throw NotFound());
|
||||
_gateway.CurrentUser = () => Asked(() => "me");
|
||||
_gateway.OrgLogins = () => Asked<IReadOnlyList<string>>(() => []);
|
||||
_gateway.AccountKind = _ => Asked(() => GitHubAccountKind.NotFound);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static NotFoundException NotFound() => new("nope", HttpStatusCode.NotFound);
|
||||
|
||||
|
||||
|
||||
private Task<T> Asked<T>(Func<T> answer)
|
||||
{
|
||||
Interlocked.Increment(ref _requests);
|
||||
try
|
||||
{
|
||||
return Task.FromResult(answer());
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
return Task.FromException<T>(failure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void EveryRequestFailsWith(Func<Exception> failure)
|
||||
{
|
||||
_gateway.OrgPages = (_, _) => Asked<IReadOnlyList<GitHubRepo>>(() => throw failure());
|
||||
_gateway.CurrentUser = () => Asked<string>(() => throw failure());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenAnOrganization(string name, params string[] repositories)
|
||||
{
|
||||
Func<string, int, Task<IReadOnlyList<GitHubRepo>>> before = _gateway.OrgPages;
|
||||
_gateway.OrgPages = (organization, page) => string.Equals(organization, name, StringComparison.Ordinal)
|
||||
? Asked<IReadOnlyList<GitHubRepo>>(() => page == 1
|
||||
? [.. repositories.Select(repository => new GitHubRepo(repository, $"https://github.example.test/{name}/{repository}.git", "main", false))]
|
||||
: [])
|
||||
: before(organization, page);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenTheTokenBelongsTo(string account, params string[] organizations)
|
||||
{
|
||||
_gateway.CurrentUser = () => Asked(() => account);
|
||||
_gateway.OrgLogins = () => Asked<IReadOnlyList<string>>(() => organizations);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenNothingIsCalled(string name)
|
||||
{
|
||||
// Nothing to set up: what this gateway does not know is not found.
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenTheTokenIsRejected()
|
||||
=> EveryRequestFailsWith(() => new AuthorizationException(HttpStatusCode.Unauthorized, null));
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenTheForgeFails()
|
||||
=> EveryRequestFailsWith(() => new ApiException("down", HttpStatusCode.ServiceUnavailable));
|
||||
}
|
||||
@@ -17,7 +17,8 @@ namespace gclo.Engine.Tests;
|
||||
/// </summary>
|
||||
public sealed class GitHubListerTests
|
||||
{
|
||||
private sealed class FakeGateway : IGitHubGateway
|
||||
/// <summary>A GitHub gateway whose every call is scripted. Also used by <see cref="GitHubListerContractTests"/>.</summary>
|
||||
internal sealed class FakeGateway : IGitHubGateway
|
||||
{
|
||||
public Func<string, int, Task<IReadOnlyList<GitHubRepo>>> OrgPages { get; set; } =
|
||||
(_, _) => Task.FromResult<IReadOnlyList<GitHubRepo>>([]);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using static gclo.Engine.Tests.FakeGiteaServer;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>The lister contract, kept by the Gitea listers.</summary>
|
||||
public sealed class GiteaListerContractTests : ListerContractTests, IDisposable
|
||||
{
|
||||
private readonly FakeGiteaServer _server = new();
|
||||
|
||||
|
||||
|
||||
public void Dispose() => _server.Dispose();
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IRepositoryLister Repositories => new GiteaRepositoryLister(_server.Api());
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IOrganizationLister Organizations => new GiteaOrganizationLister(_server.Api());
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override int RequestsMade => _server.Requests.Count;
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenAnOrganization(string name, params string[] repositories)
|
||||
=> _server.OnPage($"orgs/{name}/repos", 1, repositories.Select(repository => Repo(repository, owner: name)), more: false);
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenTheTokenBelongsTo(string account, params string[] organizations)
|
||||
=> _server
|
||||
.On("user", () => Json($$"""{"id":1,"login":"{{account}}"}"""))
|
||||
.OnPage("user/orgs", 1, organizations.Select(organization => $$"""{"id":2,"username":"{{organization}}"}"""), more: false);
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenNothingIsCalled(string name)
|
||||
{
|
||||
// Nothing to set up: what the server does not know, it answers with 404.
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenTheTokenIsRejected()
|
||||
=> _server.Otherwise = () => Refusal(HttpStatusCode.Unauthorized, "invalid username, password or token");
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void GivenTheForgeFails()
|
||||
=> _server.Otherwise = () => Refusal(HttpStatusCode.ServiceUnavailable, "down for maintenance");
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using static gclo.Engine.Tests.FakeGiteaServer;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The Gitea listers over a fake server: paging, the account fallback, what is and is
|
||||
/// not taken from the server's answers, and what every kind of failure means. The
|
||||
/// server's address is one a user typed, so the tests also pin where the token goes
|
||||
/// and that nothing the server says moves it elsewhere.
|
||||
/// </summary>
|
||||
public sealed class GiteaListerTests : IDisposable
|
||||
{
|
||||
private const string Token = "gitea-test-token";
|
||||
|
||||
private const string Host = "git.example.test";
|
||||
|
||||
private readonly FakeGiteaServer _server = new();
|
||||
|
||||
|
||||
|
||||
public void Dispose() => _server.Dispose();
|
||||
|
||||
|
||||
|
||||
private GiteaRepositoryLister Repos(TimeSpan? requestTimeout = null) => new(_server.Api(requestTimeout));
|
||||
|
||||
|
||||
|
||||
private GiteaOrganizationLister Orgs() => new(_server.Api());
|
||||
|
||||
|
||||
|
||||
private static string Account(string login) => $$"""{"id":1,"login":"{{login}}","username":"","email":"someone@example.test"}""";
|
||||
|
||||
|
||||
|
||||
private void TheTokenBelongsTo(string login) => _server.On("user", () => Json(Account(login)));
|
||||
|
||||
|
||||
|
||||
private Task<IReadOnlyList<RepoDescriptor>> List(string organization = "acme", CancellationToken cancellationToken = default)
|
||||
=> Repos().ListOrganizationRepositoriesAsync(organization, Token, cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>A body that never ends: a server that keeps sending.</summary>
|
||||
private sealed class Endless : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
Array.Fill(buffer, (byte)' ', offset, count);
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- paging
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_PagesWhileTheServerSaysThereIsANextPage_AndSortsByName()
|
||||
{
|
||||
// The second page is empty (everything on it was hidden from the token) and
|
||||
// the first is not full: neither ends a list the server says goes on.
|
||||
_server
|
||||
.OnPage("orgs/acme/repos", 1, [Repo("zeta"), Repo("Alpha")], more: true)
|
||||
.OnPage("orgs/acme/repos", 2, [], more: true)
|
||||
.OnPage("orgs/acme/repos", 3, [Repo("mid")], more: false);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List();
|
||||
|
||||
Assert.Equal(["Alpha", "mid", "zeta"], repos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
Assert.Equal(
|
||||
["orgs/acme/repos?page=1&limit=50", "orgs/acme/repos?page=2&limit=50", "orgs/acme/repos?page=3&limit=50"],
|
||||
_server.Asked,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_WhereTheServerDoesNotSay_AnEmptyPageEndsTheList()
|
||||
{
|
||||
_server
|
||||
.OnPage("orgs/acme/repos", 1, [Repo("a")])
|
||||
.OnPage("orgs/acme/repos", 2, [Repo("b")])
|
||||
.OnPage("orgs/acme/repos", 3, []);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List();
|
||||
|
||||
Assert.Equal(["a", "b"], repos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
Assert.Equal(3, _server.Asked.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_AListThatDoesNotEnd_IsNotFollowedForEver()
|
||||
{
|
||||
for(int page = 1; page <= GiteaApi.MaxPages; page++)
|
||||
{
|
||||
_server.OnPage("orgs/acme/repos", page, [Repo("same")], more: true);
|
||||
}
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains("did not answer like a Gitea server", ex.Message);
|
||||
Assert.Equal(GiteaApi.MaxPages, _server.Asked.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_DuplicateNames_AreListedOnce()
|
||||
{
|
||||
_server.OnPage("orgs/acme/repos", 1, [Repo("Tool"), Repo("tool"), Repo("other")], more: false);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List();
|
||||
|
||||
Assert.Equal(["other", "Tool"], repos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- what is taken from the answers
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_MapFields_AndTheCloneAddressIsBuiltFromTheAccountsServer_NotTakenFromTheAnswer()
|
||||
{
|
||||
_server.OnPage(
|
||||
"orgs/acme/repos",
|
||||
1,
|
||||
[Repo("tool", archived: true), Repo("fresh", empty: true), Repo("odd", branch: ""), Repo("bare", branch: null), """{"name":" "}""", "null"],
|
||||
more: false);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
new RepoDescriptor("bare", $"https://{Host}/acme/bare.git", null, false),
|
||||
new RepoDescriptor("fresh", $"https://{Host}/acme/fresh.git", null, false), // no commits: no branch to speak of
|
||||
new RepoDescriptor("odd", $"https://{Host}/acme/odd.git", null, false),
|
||||
new RepoDescriptor("tool", $"https://{Host}/acme/tool.git", "main", true),
|
||||
],
|
||||
repos);
|
||||
Assert.DoesNotContain(repos, r => r.CloneUrl.Contains("attacker", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_AServerUnderAPath_IsAskedThere_AndClonedFromThere()
|
||||
{
|
||||
var server = new FakeGiteaServer();
|
||||
server.On("/gitea/api/v1/orgs/acme/repos?page=1&limit=50", () => Json($"[{Repo("tool")}]", link: false));
|
||||
var lister = new GiteaRepositoryLister(new GiteaApi(new Uri("https://example.test/gitea"), new HttpClient(server)));
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await lister.ListOrganizationRepositoriesAsync("acme", Token);
|
||||
|
||||
Assert.Equal("https://example.test/gitea/acme/tool.git", Assert.Single(repos).CloneUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task EveryRequest_GoesToTheAccountsServer_WithTheTokenInItsHeader_WhateverTheAnswersPointAt()
|
||||
{
|
||||
// The Link headers and the clone addresses of the answers name other hosts.
|
||||
_server
|
||||
.OnPage("orgs/acme/repos", 1, [Repo("a")], more: true)
|
||||
.OnPage("orgs/acme/repos", 2, [Repo("b")], more: false);
|
||||
|
||||
await List();
|
||||
|
||||
Assert.Equal(2, _server.Requests.Count);
|
||||
Assert.All(_server.Requests, request =>
|
||||
{
|
||||
Assert.Equal(Uri.UriSchemeHttps, request.Address.Scheme);
|
||||
Assert.Equal(Host, request.Address.Host);
|
||||
Assert.Equal("token " + Token, request.Authorization);
|
||||
Assert.DoesNotContain(Token, request.Address.AbsoluteUri, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- an account that is not an organization
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_OfTheTokensOwnAccount_AreItsOwn_NotThoseOfOthersItMayWorkOn()
|
||||
{
|
||||
TheTokenBelongsTo("Me");
|
||||
_server.OnPage(
|
||||
"user/repos",
|
||||
1,
|
||||
[Repo("mine", owner: "Me"), Repo("theirs", owner: "acme"), Repo("ownerless", owner: null)],
|
||||
more: false);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List("me");
|
||||
|
||||
Assert.Equal(["mine", "ownerless"], repos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
Assert.Equal($"https://{Host}/Me/mine.git", repos[0].CloneUrl);
|
||||
Assert.Equal($"https://{Host}/me/ownerless.git", repos[1].CloneUrl); // no owner in the answer: the name that was asked for
|
||||
Assert.Equal(["orgs/me/repos?page=1&limit=50", "user", "user/repos?page=1&limit=50"], _server.Asked, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_OfAnotherAccount_AreWhatTheTokenSeesThere()
|
||||
{
|
||||
TheTokenBelongsTo("me");
|
||||
_server.OnPage("users/bob/repos", 1, [Repo("b", owner: "bob")], more: false);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await List("bob");
|
||||
|
||||
Assert.Equal($"https://{Host}/bob/b.git", Assert.Single(repos).CloneUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_NeitherOrganizationNorAccount_IsNotFound_WithASpellingHint()
|
||||
{
|
||||
TheTokenBelongsTo("me");
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List("nobody"));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains($"'nobody' was found neither as an organization nor as a user account on {Host} (404)", ex.Message);
|
||||
Assert.Contains("Check the spelling", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_WhenTheServerNamesNoAccountForTheToken_ItDoesNotAnswerLikeAGiteaServer()
|
||||
{
|
||||
_server.On("user", () => Json("""{"id":1}"""));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains("did not answer like a Gitea server", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- arguments and cancellation
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("nested/child")]
|
||||
[InlineData("name:stream")]
|
||||
public async Task Repos_ANameThatIsNotOnePlainName_IsRefusedBeforeAnyRequest(string organization)
|
||||
{
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List(organization));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains("is not an organization or account name", ex.Message);
|
||||
Assert.Empty(_server.Requests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("", Token)]
|
||||
[InlineData(" ", Token)]
|
||||
[InlineData("acme", "")]
|
||||
[InlineData("acme", " ")]
|
||||
public async Task Repos_BlankArguments_Throw(string organization, string token)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Repos().ListOrganizationRepositoriesAsync(organization, token));
|
||||
Assert.Empty(_server.Requests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_AlreadyCanceled_ThrowBeforeAnyRequest()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => List(cancellationToken: cts.Token));
|
||||
Assert.Empty(_server.Requests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_CanceledBetweenPages_Throw_AndAskForNoMore()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
_server
|
||||
.On("orgs/acme/repos?page=1&limit=50", () =>
|
||||
{
|
||||
cts.Cancel(); // while the first page is on its way
|
||||
return Json($"[{Repo("a")}]", link: true);
|
||||
})
|
||||
.OnPage("orgs/acme/repos", 2, [Repo("b")], more: false);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => List(cancellationToken: cts.Token));
|
||||
Assert.Single(_server.Asked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_CanceledDuringARequestThatHangs_IsACancellation_NotAFailureOfTheServer()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
_server.OnRequest = async (_, token) =>
|
||||
{
|
||||
cts.CancelAfter(TimeSpan.FromMilliseconds(20));
|
||||
await Task.Delay(Timeout.Infinite, token);
|
||||
};
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => List(cancellationToken: cts.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- failures
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized, ForgeAccessKind.Unauthorized, "rejected the token (401)")]
|
||||
[InlineData(HttpStatusCode.TooManyRequests, ForgeAccessKind.RateLimited, "limiting requests from this token (429")]
|
||||
[InlineData(HttpStatusCode.ProxyAuthenticationRequired, ForgeAccessKind.Unavailable, "A proxy on the way to git.example.test asked gclo to sign in (HTTP 407)")]
|
||||
[InlineData(HttpStatusCode.InternalServerError, ForgeAccessKind.Unavailable, "answered with an error on its side (HTTP 500); retry later")]
|
||||
[InlineData(HttpStatusCode.BadGateway, ForgeAccessKind.Unavailable, "answered with an error on its side (HTTP 502); retry later")]
|
||||
[InlineData(HttpStatusCode.Found, ForgeAccessKind.NotFound, "answered with a redirect (HTTP 302) instead of the Gitea API")]
|
||||
[InlineData(HttpStatusCode.PermanentRedirect, ForgeAccessKind.NotFound, "answered with a redirect (HTTP 308) instead of the Gitea API")]
|
||||
[InlineData(HttpStatusCode.BadRequest, ForgeAccessKind.NotFound, "There is no Gitea API at this address of git.example.test (HTTP 400)")]
|
||||
[InlineData(HttpStatusCode.MethodNotAllowed, ForgeAccessKind.NotFound, "There is no Gitea API at this address of git.example.test (HTTP 405)")]
|
||||
public async Task AStatusThatIsNotSuccess_HasItsKind_AndNamesTheServer(HttpStatusCode status, ForgeAccessKind kind, string said)
|
||||
{
|
||||
_server.On("orgs/acme/repos?page=1&limit=50", () => Refusal(status, "whatever the server says"));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(kind, ex.Kind);
|
||||
Assert.Contains(said, ex.Message);
|
||||
Assert.Single(_server.Requests); // nothing is asked a second time, and a redirect is not followed
|
||||
Assert.IsType<GiteaStatusException>(ex.InnerException);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnAddressWithNoApiBehindIt_SaysSo_InsteadOfBlamingTheNameThatWasAskedFor()
|
||||
{
|
||||
// Every path is 404, the 'who am I' of the account fallback too.
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains($"There is no Gitea API at this address of {Host} (HTTP 404). Check the server address of this account.", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusal_CarriesTheServersReason_OnOneLine_AndSaysWhichScopesAreNeeded()
|
||||
{
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s):\\n [read:organization]"));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Forbidden, ex.Kind);
|
||||
Assert.Equal(
|
||||
$"{Host} refused access (403): token does not have at least one of required scope(s): [read:organization]. "
|
||||
+ "Check what the access token is allowed to do: gclo reads repositories, organizations and the user "
|
||||
+ "(the scopes read:repository, read:organization and read:user).",
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("<html><body>Blocked by the web filter</body></html>")]
|
||||
[InlineData("""{"error":"no message here"}""")]
|
||||
[InlineData("")]
|
||||
public async Task ARefusalWithoutAReasonFromTheApi_SaysNone_AndCopiesNoPage(string body)
|
||||
{
|
||||
_server.On("orgs/acme/repos?page=1&limit=50", () => Json(body, HttpStatusCode.Forbidden));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.StartsWith($"{Host} refused access (403). Check what the access token is allowed to do", ex.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("web filter", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusalThatDoesNotEnd_IsNotReadForItsReason()
|
||||
{
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StreamContent(new Endless()) });
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.StartsWith($"{Host} refused access (403). ", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("<html><body>Sign in</body></html>")] // a sign-in page in front of the server
|
||||
[InlineData("null")]
|
||||
[InlineData("""{"not":"a list"}""")]
|
||||
[InlineData("""[{"name":5}]""")]
|
||||
public async Task AnAnswerThatIsNotTheApisJson_SaysThatTheAddressIsNotAGiteaServers(string body)
|
||||
{
|
||||
_server.On("orgs/acme/repos?page=1&limit=50", () => Json(body));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains($"{Host} did not answer like a Gitea server. Check the server address of this account.", ex.Message);
|
||||
Assert.DoesNotContain("Sign in", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnAnswerThatDoesNotEnd_IsNotReadForEver()
|
||||
{
|
||||
_server.On(
|
||||
"orgs/acme/repos?page=1&limit=50",
|
||||
() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new Endless()) });
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains("did not answer like a Gitea server", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AServerThatCannotBeReached_IsUnavailable_InWordsThatAreNotThePlatformsOwn()
|
||||
{
|
||||
_server.OnRequest = (_, _) => throw new HttpRequestException(
|
||||
HttpRequestError.NameResolutionError, "proxy http://user:hunter2@proxy.example.test said no");
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => List());
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, ex.Kind);
|
||||
Assert.Equal($"{Host} could not be reached: its name could not be resolved (DNS). Check the network connection and retry.", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ARequestThatTakesTooLong_IsUnavailable_NotACancellation()
|
||||
{
|
||||
_server.OnRequest = (_, token) => Task.Delay(Timeout.Infinite, token);
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => Repos(requestTimeout: TimeSpan.FromMilliseconds(30)).ListOrganizationRepositoriesAsync("acme", Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, ex.Kind);
|
||||
Assert.Equal($"{Host} did not answer in time; retry later.", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AFailureWithNoKnownMeaning_StaysWhatItIs()
|
||||
{
|
||||
_server.OnRequest = (_, _) => throw new InvalidOperationException("something nobody planned for");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(() => List());
|
||||
|
||||
Assert.IsNotType<ForgeAccessException>(ex);
|
||||
Assert.Equal("something nobody planned for", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Failures_WhatHasNoKnownMeaning_IsNotTranslated_AndACancellationByTheCallerIsNotATimeout()
|
||||
{
|
||||
GiteaApi api = _server.Api();
|
||||
using var canceled = new CancellationTokenSource();
|
||||
canceled.Cancel();
|
||||
|
||||
Assert.False(api.Knows(new InvalidOperationException("?"), CancellationToken.None));
|
||||
Assert.Throws<ArgumentException>(() => api.Translate(new InvalidOperationException("?")));
|
||||
Assert.True(api.Knows(new OperationCanceledException(), CancellationToken.None));
|
||||
Assert.False(api.Knows(new OperationCanceledException(), canceled.Token));
|
||||
|
||||
// Decided when it was asked: a cancellation that arrives afterwards changes nothing.
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, api.Translate(new OperationCanceledException()).Kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- organizations
|
||||
|
||||
[Fact]
|
||||
public async Task Orgs_TheTokensOwnAccountFirst_ThenItsOrganizationsSorted_EachOnce()
|
||||
{
|
||||
TheTokenBelongsTo("me");
|
||||
_server.OnPage(
|
||||
"user/orgs",
|
||||
1,
|
||||
["""{"id":2,"username":"zeta"}""", """{"id":3,"username":"Alpha","login":""}""", """{"id":4,"username":"ME"}""", "null", """{"id":5}""", """{"id":6,"username":"alpha"}"""],
|
||||
more: false);
|
||||
|
||||
IReadOnlyList<string> names = await Orgs().ListOrganizationsAsync(Token);
|
||||
|
||||
Assert.Equal(["me", "Alpha", "zeta"], names, StringComparer.Ordinal);
|
||||
Assert.All(_server.Requests, request => Assert.Equal("token " + Token, request.Authorization));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Orgs_ATokenThatMayNotListOrganizations_StillGetsItsOwnAccount()
|
||||
{
|
||||
TheTokenBelongsTo("me");
|
||||
_server.On(
|
||||
"user/orgs?page=1&limit=50",
|
||||
() => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s): [read:organization]"));
|
||||
|
||||
IReadOnlyList<string> names = await Orgs().ListOrganizationsAsync(Token);
|
||||
|
||||
Assert.Equal(["me"], names, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Orgs_ATokenThatMayNotReadItsOwnAccount_IsForbidden_WithTheServersReason()
|
||||
{
|
||||
_server.On("user", () => Refusal(HttpStatusCode.Forbidden, "token does not have at least one of required scope(s): [read:user]"));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => Orgs().ListOrganizationsAsync(Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Forbidden, ex.Kind);
|
||||
Assert.Contains("[read:user]", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Orgs_ATokenTheServerRejects_IsUnauthorized()
|
||||
{
|
||||
_server.On("user", () => Refusal(HttpStatusCode.Unauthorized, "invalid username, password or token"));
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAsync<ForgeAccessException>(() => Orgs().ListOrganizationsAsync(Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unauthorized, ex.Kind);
|
||||
Assert.Equal($"{Host} rejected the token (401). Check that the access token is complete and has not been deleted or expired.", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Orgs_AlreadyCanceled_OrABlankToken_AskNothing()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Orgs().ListOrganizationsAsync(Token, cts.Token));
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Orgs().ListOrganizationsAsync(" "));
|
||||
Assert.Empty(_server.Requests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Orgs_CanceledAfterTheAccountWasRead_Throw()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
TheTokenBelongsTo("me");
|
||||
_server.On("user/orgs?page=1&limit=50", () =>
|
||||
{
|
||||
cts.Cancel(); // while the answer is on its way
|
||||
return Json("[]", link: false);
|
||||
});
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Orgs().ListOrganizationsAsync(Token, cts.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- construction
|
||||
|
||||
[Fact]
|
||||
public void Listers_AreMadeForAnHttpsAddress_AndForNoOther()
|
||||
{
|
||||
_ = new GiteaRepositoryLister(new Uri("https://git.example.test"));
|
||||
_ = new GiteaOrganizationLister(new Uri("https://git.example.test/gitea/"));
|
||||
|
||||
ArgumentException plain = Assert.Throws<ArgumentException>(() => new GiteaRepositoryLister(new Uri("http://git.example.test")));
|
||||
Assert.Contains("it has to begin with https://", plain.Message);
|
||||
Assert.Throws<ArgumentException>(() => new GiteaOrganizationLister(new Uri("https://user:secret@git.example.test")));
|
||||
Assert.Throws<ArgumentNullException>(() => new GiteaRepositoryLister((Uri)null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new GiteaRepositoryLister((GiteaApi)null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new GiteaOrganizationLister((GiteaApi)null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void InProduction_RedirectsAreNotFollowed_AndCertificatesAreJudgedByThePlatformAlone()
|
||||
{
|
||||
// A redirect would carry the request, and with it the token, to another host;
|
||||
// a validation callback is how certificate checks are switched off.
|
||||
Assert.False(GiteaApi.SharedHandler.AllowAutoRedirect);
|
||||
Assert.Null(GiteaApi.SharedHandler.SslOptions.RemoteCertificateValidationCallback);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheServer_IsNamedInMessages_ByItsHost_AndItsPortWhenThatIsNotTheUsual()
|
||||
{
|
||||
Assert.Equal("git.example.test", new GiteaApi(new Uri("https://git.example.test/gitea")).Host);
|
||||
Assert.Equal("git.example.test:8443", new GiteaApi(new Uri("https://git.example.test:8443")).Host);
|
||||
Assert.Equal("https://git.example.test/gitea/", new GiteaApi(new Uri("https://GIT.example.test/gitea")).Server.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What may be the address of a Gitea server: the place an account's token is sent
|
||||
/// to and its repositories are cloned from.
|
||||
/// </summary>
|
||||
public sealed class GiteaServerUrlTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("git.example.test", "https://git.example.test/")] // typed without a scheme: https
|
||||
[InlineData("https://git.example.test", "https://git.example.test/")]
|
||||
[InlineData(" https://git.example.test/ ", "https://git.example.test/")]
|
||||
[InlineData("HTTPS://Git.Example.TEST", "https://git.example.test/")]
|
||||
[InlineData("https://git.example.test:443/", "https://git.example.test/")]
|
||||
[InlineData("https://git.example.test:8443", "https://git.example.test:8443/")]
|
||||
[InlineData("git.example.test:8443/gitea", "https://git.example.test:8443/gitea/")]
|
||||
[InlineData("https://example.test/gitea", "https://example.test/gitea/")] // a server under a path
|
||||
[InlineData("https://example.test/gitea/", "https://example.test/gitea/")]
|
||||
[InlineData("https://bücher.example.test", "https://xn--bcher-kva.example.test/")]
|
||||
[InlineData("https://10.1.2.3:3000", "https://10.1.2.3:3000/")] // a server on the local network is the usual case
|
||||
public void AnAddress_BecomesTheServersBaseAddress(string typed, string expected)
|
||||
{
|
||||
Assert.True(GiteaServerUrl.TryNormalize(typed, out Uri? server, out string? reason));
|
||||
|
||||
Assert.Equal(expected, server.AbsoluteUri);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "it is empty")]
|
||||
[InlineData("", "it is empty")]
|
||||
[InlineData(" ", "it is empty")]
|
||||
[InlineData("http://git.example.test", "it has to begin with https://")] // the token never travels unencrypted
|
||||
[InlineData("ftp://git.example.test", "it has to begin with https://")]
|
||||
[InlineData("ssh://git@git.example.test", "it has to begin with https://")]
|
||||
[InlineData("https://user@git.example.test", "it contains a user name or a password")]
|
||||
[InlineData("https://user:secret@git.example.test", "it contains a user name or a password")]
|
||||
[InlineData("https://git.example.test/?next=1", "it has a '?' or a '#' part")]
|
||||
[InlineData("https://git.example.test/#top", "it has a '?' or a '#' part")]
|
||||
[InlineData("git example test", "it contains a space or a control character")]
|
||||
[InlineData("https://git.example.test/\tx", "it contains a space or a control character")]
|
||||
[InlineData("https://git.example.test/\u0007", "it contains a space or a control character")]
|
||||
[InlineData("https://", "it is not an address")]
|
||||
[InlineData("://nothing", "it is not an address")]
|
||||
[InlineData("https://[not-an-address", "it is not an address")]
|
||||
public void WhatCannotBeAServersAddress_IsRefused_WithTheReason(string? typed, string expected)
|
||||
{
|
||||
Assert.False(GiteaServerUrl.TryNormalize(typed, out Uri? server, out string? reason));
|
||||
|
||||
Assert.Null(server);
|
||||
Assert.StartsWith(expected, reason, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the listers of every forge promise their callers, stated once and run against
|
||||
/// each forge (see the classes that derive from this one). The sync engine, the view
|
||||
/// models and the command line are written against these promises, not against a
|
||||
/// forge.
|
||||
/// </summary>
|
||||
public abstract class ListerContractTests
|
||||
{
|
||||
/// <summary>The token every call is made with.</summary>
|
||||
protected const string Token = "contract-test-token";
|
||||
|
||||
|
||||
|
||||
/// <summary>The repository lister under test.</summary>
|
||||
protected abstract IRepositoryLister Repositories { get; }
|
||||
|
||||
/// <summary>The organization lister under test.</summary>
|
||||
protected abstract IOrganizationLister Organizations { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The forge has an organization of that name whose list holds these repositories, in this order.</summary>
|
||||
protected abstract void GivenAnOrganization(string name, params string[] repositories);
|
||||
|
||||
|
||||
|
||||
/// <summary>The token belongs to that account, which is a member of these organizations (as the forge lists them).</summary>
|
||||
protected abstract void GivenTheTokenBelongsTo(string account, params string[] organizations);
|
||||
|
||||
|
||||
|
||||
/// <summary>The forge knows neither an organization nor an account of that name.</summary>
|
||||
protected abstract void GivenNothingIsCalled(string name);
|
||||
|
||||
|
||||
|
||||
/// <summary>The forge rejects the token.</summary>
|
||||
protected abstract void GivenTheTokenIsRejected();
|
||||
|
||||
|
||||
|
||||
/// <summary>The forge fails on its side.</summary>
|
||||
protected abstract void GivenTheForgeFails();
|
||||
|
||||
|
||||
|
||||
/// <summary>How many requests the forge has received.</summary>
|
||||
protected abstract int RequestsMade { get; }
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repositories_AreSortedByName_IgnoringCase_AndEachIsListedOnce()
|
||||
{
|
||||
GivenAnOrganization("acme", "zeta", "Alpha", "mid", "ALPHA");
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repos = await Repositories.ListOrganizationRepositoriesAsync("acme", Token);
|
||||
|
||||
Assert.Equal(["Alpha", "mid", "zeta"], repos.Select(r => r.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repositories_HaveAnHttpsCloneAddress_ThatEndsInTheirName()
|
||||
{
|
||||
GivenAnOrganization("acme", "tool");
|
||||
|
||||
RepoDescriptor repo = Assert.Single(await Repositories.ListOrganizationRepositoriesAsync("acme", Token));
|
||||
|
||||
var address = new Uri(repo.CloneUrl);
|
||||
Assert.Equal(Uri.UriSchemeHttps, address.Scheme);
|
||||
Assert.EndsWith("/tool.git", address.AbsolutePath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnOrganizationWithoutRepositories_IsAnEmptyList_NotAFailure()
|
||||
{
|
||||
GivenAnOrganization("empty");
|
||||
|
||||
Assert.Empty(await Repositories.ListOrganizationRepositoriesAsync("empty", Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ANameNothingIsCalled_IsAForgeFailure_OfTheKindNotFound()
|
||||
{
|
||||
GivenTheTokenBelongsTo("me");
|
||||
GivenNothingIsCalled("nobody");
|
||||
|
||||
ForgeAccessException ex = await Assert.ThrowsAnyAsync<ForgeAccessException>(
|
||||
() => Repositories.ListOrganizationRepositoriesAsync("nobody", Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ARejectedToken_IsAForgeFailure_OfTheKindUnauthorized_FromBothListers()
|
||||
{
|
||||
GivenTheTokenIsRejected();
|
||||
|
||||
ForgeAccessException fromRepositories = await Assert.ThrowsAnyAsync<ForgeAccessException>(
|
||||
() => Repositories.ListOrganizationRepositoriesAsync("acme", Token));
|
||||
ForgeAccessException fromOrganizations = await Assert.ThrowsAnyAsync<ForgeAccessException>(
|
||||
() => Organizations.ListOrganizationsAsync(Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unauthorized, fromRepositories.Kind);
|
||||
Assert.Equal(ForgeAccessKind.Unauthorized, fromOrganizations.Kind);
|
||||
Assert.DoesNotContain(Token, fromRepositories.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Token, fromOrganizations.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AForgeThatFails_IsAForgeFailure_OfTheKindUnavailable_FromBothListers()
|
||||
{
|
||||
GivenTheForgeFails();
|
||||
|
||||
ForgeAccessException fromRepositories = await Assert.ThrowsAnyAsync<ForgeAccessException>(
|
||||
() => Repositories.ListOrganizationRepositoriesAsync("acme", Token));
|
||||
ForgeAccessException fromOrganizations = await Assert.ThrowsAnyAsync<ForgeAccessException>(
|
||||
() => Organizations.ListOrganizationsAsync(Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, fromRepositories.Kind);
|
||||
Assert.Equal(ForgeAccessKind.Unavailable, fromOrganizations.Kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Organizations_BeginWithTheTokensOwnAccount_ThenTheRestSorted_EachOnce()
|
||||
{
|
||||
GivenTheTokenBelongsTo("me", "zeta", "Alpha", "ME");
|
||||
|
||||
IReadOnlyList<string> names = await Organizations.ListOrganizationsAsync(Token);
|
||||
|
||||
Assert.Equal(["me", "Alpha", "zeta"], names, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ACallThatIsAlreadyCanceled_AsksTheForgeNothing()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => Repositories.ListOrganizationRepositoriesAsync("acme", Token, cts.Token));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Organizations.ListOrganizationsAsync(Token, cts.Token));
|
||||
|
||||
Assert.Equal(0, RequestsMade);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("", Token)]
|
||||
[InlineData("acme", " ")]
|
||||
public async Task BlankArguments_AreRefused_AsArguments(string organization, string token)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Repositories.ListOrganizationRepositoriesAsync(organization, token));
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Organizations.ListOrganizationsAsync(" "));
|
||||
|
||||
Assert.Equal(0, RequestsMade);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ANameThatIsNotOnePlainName_IsNotFound_AndIsNotSentToTheForge()
|
||||
{
|
||||
ForgeAccessException ex = await Assert.ThrowsAnyAsync<ForgeAccessException>(
|
||||
() => Repositories.ListOrganizationRepositoriesAsync("../elsewhere", Token));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, ex.Kind);
|
||||
Assert.Equal(0, RequestsMade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A failed request to a forge (a refusal, or the server not answering) translated
|
||||
/// into an actionable message. Derives from <see cref="InvalidOperationException"/>
|
||||
/// so every existing catch keeps working; <see cref="Kind"/> lets a caller tell "fix
|
||||
/// your token" from "wait and retry" without parsing the message. Listers of every
|
||||
/// forge throw it; the GitHub listers throw the derived <see cref="GitHubAccessException"/>.
|
||||
/// </summary>
|
||||
public class ForgeAccessException : InvalidOperationException
|
||||
{
|
||||
/// <summary>The category of failure.</summary>
|
||||
public ForgeAccessKind Kind { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the exception with its category, user-facing message, and the cause.</summary>
|
||||
public ForgeAccessException(ForgeAccessKind kind, string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Kind = kind;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Why a request to a forge (GitHub, a Gitea server) failed, in the terms a caller
|
||||
/// can act on. The members and their values are those of <see cref="GitHubAccessKind"/>.
|
||||
/// </summary>
|
||||
public enum ForgeAccessKind
|
||||
{
|
||||
/// <summary>The token was rejected (401): expired, revoked, deleted, or malformed.</summary>
|
||||
Unauthorized,
|
||||
|
||||
/// <summary>The token is valid but may not see the target, or lacks a permission the request needs.</summary>
|
||||
Forbidden,
|
||||
|
||||
/// <summary>The server is limiting requests from this token: retry later.</summary>
|
||||
RateLimited,
|
||||
|
||||
/// <summary>
|
||||
/// No organization or account of that name is visible to the token; or, for a
|
||||
/// server at an address the user named, nothing that answers like that forge is
|
||||
/// at the address.
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// The server could not be reached, did not answer in time, or failed on its side
|
||||
/// (a network error, a timeout, HTTP 5xx): nothing is wrong with the request, retry later.
|
||||
/// </summary>
|
||||
Unavailable,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Words the failure translations of every forge share: a server that could not be
|
||||
/// reached, and a server's own reason made safe to put into a message.
|
||||
/// </summary>
|
||||
internal static class ForgeFailures
|
||||
{
|
||||
/// <summary>The longest reason from a server that is put into a message.</summary>
|
||||
private const int MaxReasonLength = 300;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The words for a request that never got an answer. Chosen by the kind of failure,
|
||||
/// never copied from the failure's own text: that text can name a proxy together
|
||||
/// with the user name and password used for it.
|
||||
/// </summary>
|
||||
/// <param name="server">What could not be reached, as the message names it ("GitHub", a host).</param>
|
||||
/// <param name="error">The kind of failure.</param>
|
||||
public static string Unreachable(string server, HttpRequestError error)
|
||||
{
|
||||
string what = error switch
|
||||
{
|
||||
HttpRequestError.NameResolutionError => "its name could not be resolved (DNS)",
|
||||
HttpRequestError.ConnectionError => "the connection could not be made",
|
||||
HttpRequestError.SecureConnectionError
|
||||
=> "a secure (TLS) connection could not be established; a proxy or a security product that inspects traffic is a common cause",
|
||||
HttpRequestError.ProxyTunnelError => "the proxy on the way refused the connection",
|
||||
HttpRequestError.Unknown => "the request failed on the way",
|
||||
_ => $"the request failed on the way ({error})",
|
||||
};
|
||||
return $"{server} could not be reached: {what}. Check the network connection and retry.";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A reason from a server as one line of bounded length that ends a sentence; empty
|
||||
/// when there is none.
|
||||
/// </summary>
|
||||
public static string OneLine(string? text)
|
||||
{
|
||||
string line = string.Join(' ', (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
if(line.Length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
if(line.Length > MaxReasonLength)
|
||||
{
|
||||
// Cut between characters, never through a pair of code units.
|
||||
int keep = char.IsHighSurrogate(line[MaxReasonLength - 1]) ? MaxReasonLength - 1 : MaxReasonLength;
|
||||
return line[..keep] + "...";
|
||||
}
|
||||
return line[^1] is '.' or '!' or '?' ? line : line + ".";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>The kind of server an organization or account lives on.</summary>
|
||||
public enum ForgeKind
|
||||
{
|
||||
/// <summary>github.com.</summary>
|
||||
GitHub,
|
||||
|
||||
/// <summary>A Gitea server, or one that speaks its API (Forgejo), at an address the user names.</summary>
|
||||
Gitea,
|
||||
}
|
||||
@@ -8,20 +8,21 @@ namespace gclo.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// A failed GitHub API request (a refusal, or GitHub not answering) translated into an actionable message. Derives from
|
||||
/// <see cref="InvalidOperationException"/> so every existing catch keeps working;
|
||||
/// <see cref="InvalidOperationException"/> (through <see cref="ForgeAccessException"/>,
|
||||
/// the type the listers of every forge throw) so every existing catch keeps working;
|
||||
/// <see cref="Kind"/> lets a caller tell "fix your token" from "wait and retry"
|
||||
/// without parsing the message — the CLI maps it to distinct exit codes.
|
||||
/// </summary>
|
||||
public sealed class GitHubAccessException : InvalidOperationException
|
||||
public sealed class GitHubAccessException : ForgeAccessException
|
||||
{
|
||||
/// <summary>The category of refusal.</summary>
|
||||
public GitHubAccessKind Kind { get; }
|
||||
/// <summary>The category of refusal. The same category, as a <see cref="ForgeAccessKind"/>, is on the base type.</summary>
|
||||
public new GitHubAccessKind Kind { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the exception with its category, user-facing message, and the Octokit cause.</summary>
|
||||
public GitHubAccessException(GitHubAccessKind kind, string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
: base((ForgeAccessKind)(int)kind, message, innerException)
|
||||
{
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ namespace gclo.Engine;
|
||||
/// </summary>
|
||||
internal static class GitHubFailures
|
||||
{
|
||||
/// <summary>The longest reason from GitHub that is put into a message.</summary>
|
||||
private const int MaxReasonLength = 300;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Waits for one gateway request, or for the caller's cancellation, whichever
|
||||
/// comes first. The gateway cannot cancel a request, so a canceled one is left
|
||||
@@ -126,7 +121,7 @@ internal static class GitHubFailures
|
||||
exception),
|
||||
|
||||
HttpRequestException network => new GitHubAccessException(
|
||||
GitHubAccessKind.Unavailable, Unreachable(network.HttpRequestError), exception),
|
||||
GitHubAccessKind.Unavailable, ForgeFailures.Unreachable("GitHub", network.HttpRequestError), exception),
|
||||
|
||||
OperationCanceledException when !canceledByCaller => new GitHubAccessException(
|
||||
GitHubAccessKind.Unavailable, "GitHub did not answer in time; retry later.", exception),
|
||||
@@ -172,7 +167,7 @@ internal static class GitHubFailures
|
||||
return document.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& document.RootElement.TryGetProperty("message", out JsonElement message)
|
||||
&& message.ValueKind == JsonValueKind.String
|
||||
? OneLine(message.GetString())
|
||||
? ForgeFailures.OneLine(message.GetString())
|
||||
: "";
|
||||
}
|
||||
catch(JsonException)
|
||||
@@ -180,48 +175,4 @@ internal static class GitHubFailures
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The words for a request that never got an answer. Chosen by the kind of failure,
|
||||
/// never copied from the failure's own text: that text can name a proxy together
|
||||
/// with the user name and password used for it.
|
||||
/// </summary>
|
||||
private static string Unreachable(HttpRequestError error)
|
||||
{
|
||||
string what = error switch
|
||||
{
|
||||
HttpRequestError.NameResolutionError => "its name could not be resolved (DNS)",
|
||||
HttpRequestError.ConnectionError => "the connection could not be made",
|
||||
HttpRequestError.SecureConnectionError
|
||||
=> "a secure (TLS) connection could not be established; a proxy or a security product that inspects traffic is a common cause",
|
||||
HttpRequestError.ProxyTunnelError => "the proxy on the way refused the connection",
|
||||
HttpRequestError.Unknown => "the request failed on the way",
|
||||
_ => $"the request failed on the way ({error})",
|
||||
};
|
||||
return $"GitHub could not be reached: {what}. Check the network connection and retry.";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A reason from GitHub as one line of bounded length that ends a sentence; empty
|
||||
/// when there is none.
|
||||
/// </summary>
|
||||
private static string OneLine(string? text)
|
||||
{
|
||||
string line = string.Join(' ', (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
if(line.Length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
if(line.Length > MaxReasonLength)
|
||||
{
|
||||
// Cut between characters, never through a pair of code units.
|
||||
int keep = char.IsHighSurrogate(line[MaxReasonLength - 1]) ? MaxReasonLength - 1 : MaxReasonLength;
|
||||
return line[..keep] + "...";
|
||||
}
|
||||
return line[^1] is '.' or '!' or '?' ? line : line + ".";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A user or an organization as the Gitea API describes it, as far as gclo reads it.
|
||||
/// A user (and the owner of a repository) carries its name as <c>login</c>; an
|
||||
/// organization in the list of a user's organizations carries it as <c>username</c>.
|
||||
/// </summary>
|
||||
/// <param name="Login">The <c>login</c> field.</param>
|
||||
/// <param name="Username">The <c>username</c> field.</param>
|
||||
internal sealed record GiteaAccount(
|
||||
[property: JsonPropertyName("login")] string? Login,
|
||||
[property: JsonPropertyName("username")] string? Username)
|
||||
{
|
||||
/// <summary>The account's name, whichever field carries it; null when neither does.</summary>
|
||||
[JsonIgnore]
|
||||
public string? Name => !string.IsNullOrWhiteSpace(Login) ? Login : !string.IsNullOrWhiteSpace(Username) ? Username : null;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The requests gclo makes to one Gitea server, and what their failures mean.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The server's address is one a user typed, so the server is not trusted beyond
|
||||
/// being the place the user chose to send their token to:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Every request goes to the address the account names (HTTPS, checked by
|
||||
/// <see cref="GiteaServerUrl"/>), with the token in its <c>Authorization</c> header.
|
||||
/// Redirects are not followed, so the token is not carried anywhere else.</item>
|
||||
/// <item>Certificates are validated by the platform. There is no switch for that.</item>
|
||||
/// <item>Clone addresses are built from the account's address
|
||||
/// (<see cref="CloneUrl"/>), not read from the server's answers: the git client
|
||||
/// gives the token to the host of the clone address.</item>
|
||||
/// <item>Answers are bounded in size, lists in length, and a request in time.</item>
|
||||
/// <item>The server's own words reach a message only as the reason of a refusal,
|
||||
/// on one line and cut to a length.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal sealed class GiteaApi
|
||||
{
|
||||
/// <summary>Items asked for per page. The server may give fewer: it has a limit of its own.</summary>
|
||||
public const int PageSize = 50;
|
||||
|
||||
/// <summary>The most pages one list is read for: a server whose list does not end is not followed for ever.</summary>
|
||||
public const int MaxPages = 2000;
|
||||
|
||||
/// <summary>The largest answer that is read.</summary>
|
||||
private const int MaxAnswerBytes = 32 * 1024 * 1024;
|
||||
|
||||
/// <summary>The most that is read of a refusal to find the server's reason in it.</summary>
|
||||
private const int MaxRefusalBytes = 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// How the requests of production are carried: redirects are not followed (the
|
||||
/// token stays on the host the account names), and certificates are validated by
|
||||
/// the platform's own rules, with no callback that could accept another.
|
||||
/// </summary>
|
||||
internal static readonly SocketsHttpHandler SharedHandler = new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// One client for every server: connections are pooled per host, and nothing that
|
||||
/// belongs to one account (its token) is kept on it.
|
||||
/// </summary>
|
||||
private static readonly HttpClient s_shared = new(SharedHandler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan, // each request has its own limit, which also covers reading the answer
|
||||
};
|
||||
|
||||
private readonly Uri _api;
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
|
||||
|
||||
|
||||
/// <summary>The server's base address, as <see cref="GiteaServerUrl"/> normalizes it.</summary>
|
||||
public Uri Server { get; }
|
||||
|
||||
/// <summary>The server as messages name it: its host, and its port when that is not 443.</summary>
|
||||
public string Host { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the client for the server at <paramref name="server"/>.</summary>
|
||||
/// <param name="server">The server's address; held to the rules of <see cref="GiteaServerUrl"/>.</param>
|
||||
/// <param name="http">Test seam: what carries the requests. The shared client when null.</param>
|
||||
/// <param name="requestTimeout">Test seam: how long one request may take, answer included. 100 seconds when null.</param>
|
||||
/// <exception cref="ArgumentException"><paramref name="server"/> is not an address a Gitea account can have.</exception>
|
||||
public GiteaApi(Uri server, HttpClient? http = null, TimeSpan? requestTimeout = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
if(!GiteaServerUrl.TryNormalize(server.OriginalString, out Uri? normalized, out string? reason))
|
||||
{
|
||||
throw new ArgumentException($"'{server.OriginalString}' cannot be the address of a Gitea server: {reason}.", nameof(server));
|
||||
}
|
||||
|
||||
Server = normalized;
|
||||
Host = normalized.Authority;
|
||||
_api = new Uri(normalized, "api/v1/");
|
||||
_http = http ?? s_shared;
|
||||
_requestTimeout = requestTimeout ?? TimeSpan.FromSeconds(100);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The address a repository is cloned from: the account's server address, the
|
||||
/// owner, the name. Built here, not taken from the server's answer, so the host
|
||||
/// that gets the token for the clone is the one the account names.
|
||||
/// </summary>
|
||||
public string CloneUrl(string owner, string name)
|
||||
=> new Uri(Server, $"{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}.git").AbsoluteUri;
|
||||
|
||||
|
||||
|
||||
/// <summary>The name of the account the token belongs to.</summary>
|
||||
/// <exception cref="GiteaUnexpectedAnswerException">The answer names no account.</exception>
|
||||
public async Task<string> GetOwnNameAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
(GiteaAccount account, _) = await SendAsync("user", token, GiteaJsonContext.Default.GiteaAccount, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return account.Name ?? throw new GiteaUnexpectedAnswerException("The answer to 'who am I' names no account.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Every item of the list at <paramref name="path"/>, page by page. The server says
|
||||
/// whether there is a next page (its <c>Link</c> header); where it does not say,
|
||||
/// an empty page ends the list. A page that is not full ends nothing: the server
|
||||
/// has a page size of its own, and it leaves out what the token may not see.
|
||||
/// </summary>
|
||||
/// <exception cref="GiteaUnexpectedAnswerException">The list did not end within <see cref="MaxPages"/> pages.</exception>
|
||||
public async Task<List<T>> ListAsync<T>(string path, string token, JsonTypeInfo<List<T?>> type, CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
var all = new List<T>();
|
||||
for(int page = 1; page <= MaxPages; page++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
(List<T?> items, bool? more) = await SendAsync($"{path}?page={page}&limit={PageSize}", token, type, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
all.AddRange(items.OfType<T>());
|
||||
if(more == false || (more is null && items.Count == 0))
|
||||
{
|
||||
return all;
|
||||
}
|
||||
}
|
||||
throw new GiteaUnexpectedAnswerException($"A list did not end after {MaxPages} pages.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="failure"/> is a failure with a known meaning, which
|
||||
/// <see cref="Translate"/> then puts into words. One without stays what it is: an
|
||||
/// unexpected error, or the caller's own cancellation.
|
||||
/// </summary>
|
||||
/// <param name="failure">What a request threw.</param>
|
||||
/// <param name="cancellationToken">
|
||||
/// The caller's token: a canceled request is a timeout only when the caller did
|
||||
/// not cancel it. This is the one moment the token is looked at.
|
||||
/// </param>
|
||||
public bool Knows(Exception failure, CancellationToken cancellationToken)
|
||||
=> Explain(failure, canceledByCaller: cancellationToken.IsCancellationRequested) is not null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ForgeAccessException"/> that says what <paramref name="failure"/>
|
||||
/// means to a caller. Only for a failure that <see cref="Knows"/> accepted: whether
|
||||
/// a canceled request was the caller's doing was decided there and is not decided
|
||||
/// again here, so a cancellation that arrives in between changes nothing.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">The failure has no known meaning.</exception>
|
||||
public ForgeAccessException Translate(Exception failure)
|
||||
=> Explain(failure, canceledByCaller: false)
|
||||
?? throw new ArgumentException("This failure has no known meaning; ask Knows first.", nameof(failure));
|
||||
|
||||
|
||||
|
||||
private ForgeAccessException? Explain(Exception failure, bool canceledByCaller)
|
||||
{
|
||||
const string CheckTheAddress = "Check the server address of this account.";
|
||||
return failure switch
|
||||
{
|
||||
GiteaStatusException { Status: HttpStatusCode.Unauthorized } => new ForgeAccessException(
|
||||
ForgeAccessKind.Unauthorized,
|
||||
$"{Host} rejected the token (401). Check that the access token is complete and has not been deleted or expired.",
|
||||
failure),
|
||||
|
||||
GiteaStatusException { Status: HttpStatusCode.Forbidden } refused => new ForgeAccessException(
|
||||
ForgeAccessKind.Forbidden,
|
||||
(refused.Reason.Length == 0 ? $"{Host} refused access (403)." : $"{Host} refused access (403): {refused.Reason}")
|
||||
+ " Check what the access token is allowed to do: gclo reads repositories, organizations and the user "
|
||||
+ "(the scopes read:repository, read:organization and read:user).",
|
||||
failure),
|
||||
|
||||
GiteaStatusException { Status: HttpStatusCode.TooManyRequests } => new ForgeAccessException(
|
||||
ForgeAccessKind.RateLimited,
|
||||
$"{Host} is limiting requests from this token (429: too many requests); retry in a minute.",
|
||||
failure),
|
||||
|
||||
GiteaStatusException { Status: HttpStatusCode.ProxyAuthenticationRequired } => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable,
|
||||
$"A proxy on the way to {Host} asked gclo to sign in (HTTP 407), which it cannot do. "
|
||||
+ "Check the proxy settings of this machine.",
|
||||
failure),
|
||||
|
||||
GiteaStatusException { Status: >= HttpStatusCode.InternalServerError } server => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable,
|
||||
$"{Host} answered with an error on its side (HTTP {(int)server.Status}); retry later.",
|
||||
failure),
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// What is left of the statuses says that the address is not a Gitea //
|
||||
// server's: a redirect (never followed: the token stays where the //
|
||||
// account says), a page that is not there, a request it does not take. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
GiteaStatusException { Status: >= HttpStatusCode.MultipleChoices and < HttpStatusCode.BadRequest } redirect => new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"{Host} answered with a redirect (HTTP {(int)redirect.Status}) instead of the Gitea API. {CheckTheAddress} "
|
||||
+ "It has to be the address the server itself uses.",
|
||||
failure),
|
||||
|
||||
GiteaStatusException other => new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"There is no Gitea API at this address of {Host} (HTTP {(int)other.Status}). {CheckTheAddress}",
|
||||
failure),
|
||||
|
||||
GiteaUnexpectedAnswerException => new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"{Host} did not answer like a Gitea server. {CheckTheAddress} "
|
||||
+ "A sign-in page or a proxy in front of the server answers like this too.",
|
||||
failure),
|
||||
|
||||
HttpRequestException network => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable, ForgeFailures.Unreachable(Host, network.HttpRequestError), failure),
|
||||
|
||||
OperationCanceledException when !canceledByCaller => new ForgeAccessException(
|
||||
ForgeAccessKind.Unavailable, $"{Host} did not answer in time; retry later.", failure),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One request: the answer read as <typeparamref name="T"/>, and whether the server
|
||||
/// says there is a next page (null when it does not say).
|
||||
/// </summary>
|
||||
private async Task<(T Value, bool? More)> SendAsync<T>(
|
||||
string relative, string token, JsonTypeInfo<T> type, CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
limit.CancelAfter(_requestTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_api, relative));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("token", token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("gclo", null));
|
||||
|
||||
using HttpResponseMessage response = await _http
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, limit.Token).ConfigureAwait(false);
|
||||
if(!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new GiteaStatusException(response.StatusCode, await ReasonOfAsync(response, limit.Token).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
byte[] answer = await ReadAsync(response.Content, MaxAnswerBytes, limit.Token).ConfigureAwait(false)
|
||||
?? throw new GiteaUnexpectedAnswerException("The answer is larger than gclo reads.");
|
||||
T? value;
|
||||
try
|
||||
{
|
||||
value = JsonSerializer.Deserialize(answer, type);
|
||||
}
|
||||
catch(JsonException notTheApi)
|
||||
{
|
||||
throw new GiteaUnexpectedAnswerException("The answer is not the JSON the API gives.", notTheApi);
|
||||
}
|
||||
|
||||
bool? more = response.Headers.TryGetValues("Link", out IEnumerable<string>? links)
|
||||
? links.Any(link => link.Contains("rel=\"next\"", StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
return (value ?? throw new GiteaUnexpectedAnswerException("The answer is empty."), more);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The reason the server gave for a refusal: the <c>message</c> of the JSON
|
||||
/// document it answered with. Anything else (no body, a page of HTML, too much)
|
||||
/// has no reason in it.
|
||||
/// </summary>
|
||||
private static async Task<string> ReasonOfAsync(HttpResponseMessage refusal, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[]? body = await ReadAsync(refusal.Content, MaxRefusalBytes, cancellationToken).ConfigureAwait(false);
|
||||
if(body is null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return ForgeFailures.OneLine(JsonSerializer.Deserialize(body, GiteaJsonContext.Default.GiteaRefusal)?.Message);
|
||||
}
|
||||
catch(JsonException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The bytes of <paramref name="content"/>, or null when there are more than <paramref name="most"/>.</summary>
|
||||
private static async Task<byte[]?> ReadAsync(HttpContent content, int most, CancellationToken cancellationToken)
|
||||
{
|
||||
using Stream body = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var read = new MemoryStream();
|
||||
byte[] chunk = new byte[81920];
|
||||
while(true)
|
||||
{
|
||||
int count = await body.ReadAsync(chunk, cancellationToken).ConfigureAwait(false);
|
||||
if(count == 0)
|
||||
{
|
||||
return read.ToArray();
|
||||
}
|
||||
if(read.Length + count > most)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
read.Write(chunk, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON reading for the Gitea API's answers: no reflection, so the
|
||||
/// engine stays trimmable, and no JSON package beyond the framework's.
|
||||
/// </summary>
|
||||
[JsonSerializable(typeof(List<GiteaRepository?>))]
|
||||
[JsonSerializable(typeof(List<GiteaAccount?>))]
|
||||
[JsonSerializable(typeof(GiteaAccount))]
|
||||
[JsonSerializable(typeof(GiteaRefusal))]
|
||||
internal sealed partial class GiteaJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Lists the account a token belongs to and the organizations it is a member of, on
|
||||
/// a Gitea server (or one that speaks its API). The server is the one given at
|
||||
/// construction: the token of a call is sent there and nowhere else.
|
||||
/// </summary>
|
||||
public sealed class GiteaOrganizationLister : IOrganizationLister
|
||||
{
|
||||
private readonly GiteaApi _api;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the lister for the server at <paramref name="server"/>.</summary>
|
||||
/// <param name="server">The server's address, as <see cref="GiteaServerUrl.TryNormalize"/> accepts it (HTTPS).</param>
|
||||
/// <exception cref="ArgumentException"><paramref name="server"/> is not an address a Gitea account can have.</exception>
|
||||
public GiteaOrganizationLister(Uri server)
|
||||
: this(new GiteaApi(server))
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Test seam: the requests go through <paramref name="api"/>.</summary>
|
||||
internal GiteaOrganizationLister(GiteaApi api)
|
||||
=> _api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(token);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
string own;
|
||||
List<GiteaAccount> organizations;
|
||||
try
|
||||
{
|
||||
// The token's own account is a valid sync target too, so it heads the list.
|
||||
own = await _api.GetOwnNameAsync(token, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
organizations = await _api
|
||||
.ListAsync("user/orgs", token, GiteaJsonContext.Default.ListGiteaAccount, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch(GiteaStatusException noScope) when(noScope.Status == HttpStatusCode.Forbidden)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Degraded mode: a token without the read:organization scope may //
|
||||
// not list organizations, and is otherwise fine. The personal //
|
||||
// account is still a usable sync target, and an organization can //
|
||||
// be typed by hand. //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
return [own];
|
||||
}
|
||||
}
|
||||
catch(Exception failure) when(_api.Knows(failure, cancellationToken))
|
||||
{
|
||||
// What the failure means to a caller is decided in one place, for both listers.
|
||||
throw _api.Translate(failure);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = new List<string> { own };
|
||||
result.AddRange(organizations
|
||||
.Select(organization => organization.Name)
|
||||
.OfType<string>()
|
||||
.Where(name => !string.Equals(name, own, StringComparison.OrdinalIgnoreCase))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>The document a Gitea server answers a refused request with.</summary>
|
||||
/// <param name="Message">The server's reason.</param>
|
||||
internal sealed record GiteaRefusal([property: JsonPropertyName("message")] string? Message);
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A repository as the Gitea API describes it, as far as gclo reads it. The clone
|
||||
/// address the server gives is deliberately not read: gclo builds it from the
|
||||
/// address the account names (see <see cref="GiteaApi.CloneUrl"/>).
|
||||
/// </summary>
|
||||
/// <param name="Name">The repository's name.</param>
|
||||
/// <param name="Owner">The account that owns it.</param>
|
||||
/// <param name="DefaultBranch">Its default branch.</param>
|
||||
/// <param name="Archived">Whether it is archived.</param>
|
||||
/// <param name="Empty">Whether it has no commits.</param>
|
||||
internal sealed record GiteaRepository(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("owner")] GiteaAccount? Owner,
|
||||
[property: JsonPropertyName("default_branch")] string? DefaultBranch,
|
||||
[property: JsonPropertyName("archived")] bool Archived,
|
||||
[property: JsonPropertyName("empty")] bool Empty);
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Lists the repositories of an organization or account on a Gitea server (or one
|
||||
/// that speaks its API) through the server's REST API. The server is the one given
|
||||
/// at construction: the token of a call is sent there and nowhere else, and the
|
||||
/// clone address of every repository is on that server.
|
||||
/// </summary>
|
||||
public sealed class GiteaRepositoryLister : IRepositoryLister
|
||||
{
|
||||
private readonly GiteaApi _api;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the lister for the server at <paramref name="server"/>.</summary>
|
||||
/// <param name="server">The server's address, as <see cref="GiteaServerUrl.TryNormalize"/> accepts it (HTTPS).</param>
|
||||
/// <exception cref="ArgumentException"><paramref name="server"/> is not an address a Gitea account can have.</exception>
|
||||
public GiteaRepositoryLister(Uri server)
|
||||
: this(new GiteaApi(server))
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Test seam: the requests go through <paramref name="api"/>.</summary>
|
||||
internal GiteaRepositoryLister(GiteaApi api)
|
||||
=> _api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
|
||||
string organization, string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(organization);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(token);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The name goes into request paths, into clone addresses and, with the //
|
||||
// org-subfolder option and the WSL recovery, into local paths. Anything //
|
||||
// that is not one plain name is refused before a request is made. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
if(!RepositoryPathResolver.TryValidateName(organization, out string? invalidName))
|
||||
{
|
||||
throw new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"'{organization}' is not an organization or account name ({invalidName}). Check the spelling.");
|
||||
}
|
||||
|
||||
string name = Uri.EscapeDataString(organization);
|
||||
List<GiteaRepository> repositories;
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
repositories = await _api
|
||||
.ListAsync($"orgs/{name}/repos", token, GiteaJsonContext.Default.ListGiteaRepository, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch(GiteaStatusException notAnOrganization) when(notAnOrganization.Status == HttpStatusCode.NotFound)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// No organization of that name that the token can see: it is a //
|
||||
// user account, or nothing. The token's own account gets its own //
|
||||
// repositories (the private ones too); another account the ones //
|
||||
// the token can see there. //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
string own = await _api.GetOwnNameAsync(token, cancellationToken).ConfigureAwait(false);
|
||||
bool isOwn = string.Equals(own, organization, StringComparison.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
repositories = await _api
|
||||
.ListAsync(isOwn ? "user/repos" : $"users/{name}/repos", token, GiteaJsonContext.Default.ListGiteaRepository, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch(GiteaStatusException nobody) when(nobody.Status == HttpStatusCode.NotFound && !isOwn)
|
||||
{
|
||||
throw new ForgeAccessException(
|
||||
ForgeAccessKind.NotFound,
|
||||
$"'{organization}' was found neither as an organization nor as a user account on {_api.Host} (404), "
|
||||
+ "or the token cannot see it. Check the spelling.",
|
||||
nobody);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception failure) when(failure is not ForgeAccessException && _api.Knows(failure, cancellationToken))
|
||||
{
|
||||
// What the failure means to a caller is decided in one place, for both listers.
|
||||
throw _api.Translate(failure);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The list of the token's own repositories also holds repositories of //
|
||||
// other owners that it may work on. Only what the named account owns //
|
||||
// is its repository: the rest would be cloned under a wrong name, from //
|
||||
// an address that is not theirs. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
return [.. repositories
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Name)
|
||||
&& (r.Owner?.Name is null || string.Equals(r.Owner.Name, organization, StringComparison.OrdinalIgnoreCase)))
|
||||
// A repository created while the list is read shifts the pages and can
|
||||
// repeat an item; duplicates would race two clones into one folder.
|
||||
.DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(r => new RepoDescriptor(
|
||||
r.Name!,
|
||||
_api.CloneUrl(r.Owner?.Name ?? organization, r.Name!),
|
||||
r.Empty || string.IsNullOrWhiteSpace(r.DefaultBranch) ? null : r.DefaultBranch,
|
||||
r.Archived))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Turns what a user typed as the address of a Gitea server into the server's base
|
||||
/// address, or says why it cannot be one. The base address is where the token of an
|
||||
/// account is sent and where its repositories are cloned from, so it is held to a
|
||||
/// few rules: HTTPS only, no user name or password in it, nothing after the path.
|
||||
/// </summary>
|
||||
public static class GiteaServerUrl
|
||||
{
|
||||
/// <summary>
|
||||
/// The base address for <paramref name="text"/>: <c>https</c>, the host (and a
|
||||
/// port that is not 443), and the path with one slash at its end, so that paths
|
||||
/// are appended to it. A host typed without a scheme is taken as <c>https</c>.
|
||||
/// </summary>
|
||||
/// <param name="text">What was typed, for example <c>git.example.com</c> or <c>https://example.com/gitea</c>.</param>
|
||||
/// <param name="server">The base address; null when there is none.</param>
|
||||
/// <param name="reason">Why <paramref name="text"/> is not a server address; null when it is one.</param>
|
||||
public static bool TryNormalize(string? text, [NotNullWhen(true)] out Uri? server, [NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
server = null;
|
||||
string typed = (text ?? "").Trim();
|
||||
if(typed.Length == 0)
|
||||
{
|
||||
reason = "it is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(typed.Any(c => char.IsWhiteSpace(c) || char.IsControl(c)))
|
||||
{
|
||||
reason = "it contains a space or a control character";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!typed.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
typed = "https://" + typed;
|
||||
}
|
||||
|
||||
if(!Uri.TryCreate(typed, UriKind.Absolute, out Uri? parsed) || parsed.Host.Length == 0)
|
||||
{
|
||||
reason = "it is not an address";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The token travels in every request: never over a connection that is //
|
||||
// not encrypted, and there is no setting that allows it. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
reason = "it has to begin with https://";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(parsed.UserInfo.Length > 0)
|
||||
{
|
||||
reason = "it contains a user name or a password; the token is entered separately";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(parsed.Query.Length > 0 || parsed.Fragment.Length > 0)
|
||||
{
|
||||
reason = "it has a '?' or a '#' part; only the server's own address is needed";
|
||||
return false;
|
||||
}
|
||||
|
||||
string path = parsed.AbsolutePath.EndsWith('/') ? parsed.AbsolutePath : parsed.AbsolutePath + "/";
|
||||
server = new UriBuilder(Uri.UriSchemeHttps, parsed.IdnHost, parsed.IsDefaultPort ? -1 : parsed.Port, path).Uri;
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A Gitea server answered a request with a status that is not success. Internal to
|
||||
/// the Gitea listers: they decide what a status means where it occurred (a 404 for
|
||||
/// an organization is "perhaps a user account"), and <see cref="GiteaApi.Translate"/>
|
||||
/// puts the rest into words.
|
||||
/// </summary>
|
||||
internal sealed class GiteaStatusException : Exception
|
||||
{
|
||||
/// <summary>The HTTP status of the answer.</summary>
|
||||
public HttpStatusCode Status { get; }
|
||||
|
||||
/// <summary>The server's own reason, on one line and of bounded length; empty when it gave none.</summary>
|
||||
public string Reason { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the exception for a status and the reason the server gave.</summary>
|
||||
public GiteaStatusException(HttpStatusCode status, string reason)
|
||||
: base($"The server answered with HTTP {(int)status}.")
|
||||
{
|
||||
Status = status;
|
||||
Reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What came back from the address of a Gitea server is not what the Gitea API
|
||||
/// answers: not JSON, JSON of another shape, an answer without end, a list without
|
||||
/// end. Internal to the Gitea listers; <see cref="GiteaApi.Translate"/> puts it into words.
|
||||
/// </summary>
|
||||
internal sealed class GiteaUnexpectedAnswerException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception with what was unexpected and, where there is one, the cause.</summary>
|
||||
public GiteaUnexpectedAnswerException(string what, Exception? innerException = null)
|
||||
: base(what, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,16 @@
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>Lists the GitHub organizations a token can see.</summary>
|
||||
/// <summary>Lists the organizations a token can see on a forge (GitHub, a Gitea server).</summary>
|
||||
public interface IOrganizationLister
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the login names of organizations the token's user belongs to.
|
||||
/// May legitimately be empty for tokens without organization read access
|
||||
/// (fine-grained PATs, classic PATs missing read:org) — callers should still
|
||||
/// allow manual organization entry.
|
||||
/// Returns the token's own account, then the names of the organizations its
|
||||
/// user belongs to. May hold the account alone for tokens without organization
|
||||
/// read access (GitHub: fine-grained PATs, classic PATs missing read:org; Gitea:
|
||||
/// a token without read:organization) — callers should still allow manual
|
||||
/// organization entry.
|
||||
/// </summary>
|
||||
/// <exception cref="ForgeAccessException">The forge refused the request, or could not be reached.</exception>
|
||||
Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -6,10 +6,11 @@
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>Lists every repository in a GitHub organization.</summary>
|
||||
/// <summary>Lists every repository of an organization or account on a forge (GitHub, a Gitea server).</summary>
|
||||
public interface IRepositoryLister
|
||||
{
|
||||
/// <summary>Returns all repositories in the organization, following pagination to the end.</summary>
|
||||
/// <exception cref="ForgeAccessException">The forge refused the request, or could not be reached.</exception>
|
||||
Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
|
||||
string organization, string token, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
# gclo.Engine
|
||||
|
||||
The core engine of [gclo](https://git.kof22.com/KofTwentyTwo/gclo) (Git Clone Large
|
||||
Organizations). It lists every repository in a GitHub organization or user account
|
||||
and clones or fast-forward-pulls all of them in parallel, using LibGit2Sharp for
|
||||
git transport and Octokit for the GitHub API. On Windows it validates every tree
|
||||
Organizations). It lists every repository in a GitHub organization or user account,
|
||||
or in one on a Gitea server, and clones or fast-forward-pulls all of them in
|
||||
parallel, using LibGit2Sharp for git transport, Octokit for the GitHub API, and
|
||||
plain HTTPS for the Gitea API. On Windows it validates every tree
|
||||
path before checkout and offers structured rename/skip recovery for paths that
|
||||
git allows but Windows cannot create.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>A repository discovered in the GitHub organization.</summary>
|
||||
/// <summary>A repository discovered in an organization or account.</summary>
|
||||
/// <param name="Name">Repository name (unique within the org; used as the local folder name).</param>
|
||||
/// <param name="CloneUrl">HTTPS clone URL.</param>
|
||||
/// <param name="DefaultBranch">Default branch name; null for empty repositories.</param>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<RepositoryUrl>https://git.kof22.com/KofTwentyTwo/gclo</RepositoryUrl>
|
||||
<PackageProjectUrl>https://git.kof22.com/KofTwentyTwo/gclo</PackageProjectUrl>
|
||||
<PackageTags>github;git;clone;mirror;organization;libgit2sharp;octokit</PackageTags>
|
||||
<PackageTags>github;gitea;forgejo;git;clone;mirror;organization;libgit2sharp;octokit</PackageTags>
|
||||
<PackageReadmeFile>PACKAGE.md</PackageReadmeFile>
|
||||
<!-- Publish-quality package: SourceLink to the public repo, symbols in a
|
||||
.snupkg, untracked (generated) sources embedded so step-through works. -->
|
||||
|
||||
Reference in new issue
Block a user