diff --git a/README.md b/README.md index 4526ae2..b46319f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ organisation, and you may not run it as a service for other people. See ## What it does -**58 tools across four providers** — Microsoft 365, Gmail, YouTrack and GitHub — all +**58 tools across five providers** — Microsoft 365, Gmail, YouTrack, GitHub and Gitea/Forgejo — all resolved through the same named-connection model. | Area | Tools | @@ -49,7 +49,7 @@ resolved through the same named-connection model. | People | `find_person` `list_contacts` `save_contact` `update_contact` | | Teams chats | `list_chats` `read_chat` `send_chat_message` | | Issues (YouTrack) | `create_issue` `get_issue` `update_issue` `search_issues` `comment_on_issue` `list_projects` `create_project` | -| Code (GitHub) | `github_repositories` `github_repo_names` `github_issues` `github_pull_requests` `github_commits` `github_item` `github_triage` `github_files` `github_file` | +| Code (GitHub, Gitea, Forgejo) | `github_repositories` `github_repo_names` `github_issues` `github_pull_requests` `github_commits` `github_item` `github_triage` `github_files` `github_file` | | Files and jobs | `stage_file` `list_files` `check_queue` + a web approval queue | | Feedback | `report_friction` — the agent files its own bug reports mid-task | @@ -68,6 +68,13 @@ Outlook, and the tools say what they did in Gmail's own terms rather than preten Gmail settings permission Outpost does not ask for. - Exact times are in UTC: Gmail has no mailbox time zone Outpost can read. +**Gitea and Forgejo** serve every *Code* row (OP2-84) through one connector — Forgejo is a +Gitea fork with the same `/api/v1`. The tools keep their `github_*` names. Add one with the +server's address (there's no default host) and an access token with read on *repository*, +*issue* and *user*. "Awaiting my review" needs Gitea 1.20+ or a Forgejo built on it. Gitea +search can't be limited to a single repository, so per-repository personal searches go by +owner and filter by name. Drafts are the `draft` flag or a `WIP:` title. + Large attachments stream to Graph in 320 KiB-multiple chunks from object storage and never buffer in memory, so sending is a resumable job rather than a tool call that times out. @@ -115,7 +122,7 @@ docker compose up -d | garage (S3) | http://localhost:3900 | buckets `op2-staging`, `op2-attachments` | | vault (dev) | http://localhost:8200 | throwaway; mirrors `op2-transit/op2-kek` | -A **self-hosted** GitHub Enterprise or YouTrack behind a private certificate authority +A **self-hosted** GitHub Enterprise, Gitea, Forgejo or YouTrack behind a private certificate authority needs that CA named: mount the PEM into the container and set `OutPost2__TrustedRoots__CaCertPaths__0`. Nothing else changes — with no roots configured, the platform's own certificate validation is used untouched, and a root that @@ -171,9 +178,9 @@ Two things are on you and are **not** defaults: ## Design decisions worth knowing **A connection is not a mailbox.** It is an authorised link to one instance of one -provider, with a user-chosen name. Microsoft and Google (OAuth), YouTrack and GitHub (PAT) -are four providers under one model; the same resolver serves all of them unchanged, and -mail is routed to the right provider's implementation by the connection alone. +provider, with a user-chosen name. Microsoft and Google (OAuth), YouTrack, GitHub and Gitea (PAT) +are five providers under one model; the same resolver serves all of them unchanged, and +mail and code are routed to the right provider's implementation by the connection alone. **Labels are unique per user, defaults are per provider.** My "work" is not your "work", and "check email" and "my open tickets" resolve independently. Both are enforced by @@ -255,6 +262,7 @@ a Gmail connection (and `OP2_PROBE_SEND=1` for its sending half). | Mail: read, search, send, reply, forward, file, flag, categorise | done | | Attachments: staging, resumable large send, inbound extraction | done | | YouTrack and GitHub providers | done | +| Gitea / Forgejo (code tools, self-hosted URL) | done against mocked HTTP; not yet run against a live server | | OAuth 2.1 server so an MCP client can connect | done | | Voice mode reaching the connector | verified in the field | | Calendar, contacts, free/busy | done | diff --git a/src/OutPost2.Core/Connections/CapabilityCatalog.cs b/src/OutPost2.Core/Connections/CapabilityCatalog.cs index 4ddd053..c076971 100644 --- a/src/OutPost2.Core/Connections/CapabilityCatalog.cs +++ b/src/OutPost2.Core/Connections/CapabilityCatalog.cs @@ -84,7 +84,7 @@ private sealed record Entry(string Phrase, CapabilityRisk Level); ], ProviderKind.YouTrack => [Capabilities.IssuesRead, Capabilities.IssuesWrite, Capabilities.IssuesAdmin], - ProviderKind.GitHub => + ProviderKind.GitHub or ProviderKind.Gitea => [Capabilities.CodeRead, Capabilities.CodeWrite], // Mail only, deliberately (OP2-278). Declaring a capability is what makes a tool @@ -160,7 +160,7 @@ public static CapabilityRisk Risk(string capability) => { ProviderKind.Microsoft or ProviderKind.Google => $"check {label} email", ProviderKind.YouTrack => $"what's open in {label}", - ProviderKind.GitHub => $"what pull requests am I blocking on {label}", + ProviderKind.GitHub or ProviderKind.Gitea => $"what pull requests am I blocking on {label}", _ => label, }; @@ -170,6 +170,7 @@ public static CapabilityRisk Risk(string capability) => ProviderKind.Microsoft => "Microsoft mail", ProviderKind.YouTrack => "YouTrack issue trackers", ProviderKind.GitHub => "GitHub pull requests and issues", + ProviderKind.Gitea => "Gitea and Forgejo pull requests and issues", ProviderKind.Google => "Gmail", _ => provider.ToString(), }; @@ -183,7 +184,7 @@ public static CapabilityRisk Risk(string capability) => ProviderKind.YouTrack => "Chosen on purpose. One tracker per client: answering about the wrong client's " + "tickets is worse than being asked.", - ProviderKind.GitHub => + ProviderKind.GitHub or ProviderKind.Gitea => "Chosen on purpose. With one account Claude still confirms it, so a second " + "account added later never gets used by accident.", _ => diff --git a/src/OutPost2.Core/Connections/Connection.cs b/src/OutPost2.Core/Connections/Connection.cs index 3b436d0..871cb8e 100644 --- a/src/OutPost2.Core/Connections/Connection.cs +++ b/src/OutPost2.Core/Connections/Connection.cs @@ -153,6 +153,14 @@ public enum ProviderKind /// so adding it changes no column and needs no migration. /// Google, + + /// + /// A self-hosted Gitea or Forgejo server (OP2-84). One provider covers both: Forgejo is + /// a Gitea fork serving the same /api/v1. GitHub-like but not wire-compatible, + /// so it has its own implementation behind the same code tools. Stored as its name, + /// like the rest, so adding it needs no migration. + /// + Gitea, } public enum AuthKind diff --git a/src/OutPost2.Infrastructure/Code/CodeProviders.cs b/src/OutPost2.Infrastructure/Code/CodeProviders.cs new file mode 100644 index 0000000..6b0b125 --- /dev/null +++ b/src/OutPost2.Infrastructure/Code/CodeProviders.cs @@ -0,0 +1,74 @@ +using OutPost2.Core.Connections; + +namespace OutPost2.Infrastructure.Code; + +/// +/// One code host's implementation of (OP2-84), the way mail +/// got IMailProvider. The interface keeps its GitHub name because every code tool +/// and the repository resolver already speak it; what it describes — repositories, +/// commits, pull requests, issues, files — is what any of these servers has. +/// +public interface ICodeProvider : IGitHubService +{ + ProviderKind Provider { get; } +} + +/// +/// Sends each call to the implementation for the connection's provider, and does nothing +/// else. Anything provider-specific — how "awaiting my review" is asked, what a draft is — +/// belongs in the provider, where it is tested against that provider's wire format. +/// +public sealed class CodeServiceRouter(IEnumerable providers) : IGitHubService +{ + private readonly Dictionary _by = Index(providers); + + private static Dictionary Index(IEnumerable providers) + { + var by = new Dictionary(); + foreach (var p in providers) + if (!by.TryAdd(p.Provider, p)) + throw new InvalidOperationException($"Two code providers are registered for {p.Provider}."); + return by; + } + + public bool Supports(ProviderKind provider) => _by.ContainsKey(provider); + + /// + /// A connection of a kind with no code implementation is a plain sentence, not a call + /// to GitHub with somebody's Gitea token. + /// + private IGitHubService For(Connection c) => + _by.TryGetValue(c.Provider, out var p) + ? p + : throw new GitHubException( + $"{c.Label} is a {CapabilityCatalog.ProviderHeading(c.Provider)} connection, and Outpost can't read code on it."); + + public Task TriageAsync(Connection c, CancellationToken ct = default) => + For(c).TriageAsync(c, ct); + + public Task> ListRepositoriesAsync(Connection c, CancellationToken ct = default) => + For(c).ListRepositoriesAsync(c, ct); + + public Task> ListCommitsAsync( + Connection c, string repo, string? branch, int count, CancellationToken ct = default) => + For(c).ListCommitsAsync(c, repo, branch, count, ct); + + public Task> ListPullRequestsAsync( + Connection c, PullRequestScope scope, string? repo, int count, CancellationToken ct = default) => + For(c).ListPullRequestsAsync(c, scope, repo, count, ct); + + public Task> ListIssuesAsync( + Connection c, IssueScope scope, string? repo, int count, CancellationToken ct = default) => + For(c).ListIssuesAsync(c, scope, repo, count, ct); + + public Task GetAsync(Connection c, string repo, int number, CancellationToken ct = default) => + For(c).GetAsync(c, repo, number, ct); + + public Task?> ListPathAsync( + Connection c, string repo, string? path, string? gitRef, CancellationToken ct = default) => + For(c).ListPathAsync(c, repo, path, gitRef, ct); + + public Task ReadFileAsync( + Connection c, string repo, string path, string? gitRef, int maxChars, CancellationToken ct = default) => + For(c).ReadFileAsync(c, repo, path, gitRef, maxChars, ct); +} diff --git a/src/OutPost2.Infrastructure/Code/GitHubService.cs b/src/OutPost2.Infrastructure/Code/GitHubService.cs index 61e8d00..4af82e2 100644 --- a/src/OutPost2.Infrastructure/Code/GitHubService.cs +++ b/src/OutPost2.Infrastructure/Code/GitHubService.cs @@ -154,8 +154,10 @@ Task> ListIssuesAsync( public sealed class GitHubService( IHttpClientFactory httpFactory, IConnectionStore connections, - ILogger log) : IGitHubService + ILogger log) : ICodeProvider { + public ProviderKind Provider => ProviderKind.GitHub; + private async Task ClientAsync(Connection c, CancellationToken ct) { var token = await connections.GetCredentialAsync(c, ct) @@ -604,4 +606,9 @@ private sealed record CommitSignature } } +/// +/// A code-host failure, said in one plain sentence. The code tools catch this one type +/// whichever server is behind the connection — GitHub or Gitea/Forgejo (OP2-84) — so the +/// name is historical rather than a promise about the provider. +/// public sealed class GitHubException(string message) : Exception(message); diff --git a/src/OutPost2.Infrastructure/Code/GiteaService.cs b/src/OutPost2.Infrastructure/Code/GiteaService.cs new file mode 100644 index 0000000..7e882a1 --- /dev/null +++ b/src/OutPost2.Infrastructure/Code/GiteaService.cs @@ -0,0 +1,535 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using OutPost2.Core.Connections; +using OutPost2.Infrastructure.Connections; + +namespace OutPost2.Infrastructure.Code; + +/// +/// Gitea and Forgejo behind the code tools (OP2-84), over /api/v1. +/// +/// GitHub-like, not GitHub-compatible, and the differences are the whole of this class: +/// +/// +/// There is no search language. "Awaiting my review", "mine", "assigned to me" and +/// "mentioning me" are boolean flags on repos/issues/search (review_requested +/// needs Gitea 1.20 or Forgejo 1.20+), and that endpoint cannot be limited to one +/// repository — only to an owner — so a per-repository question is asked by owner and +/// filtered by name here. +/// Counts come from the X-Total-Count header, not a total_count field. +/// A draft is pull_request.draft on newer servers, and a WIP: title +/// prefix on every server — Gitea's original convention, still honoured by its UI. +/// Pages are limit, not per_page, capped by the server (50 by default). +/// Raw file bytes are repos/{repo}/raw/{path}, not a media type. +/// +/// +/// Written against Gitea's published API (swagger, 1.20+) and Forgejo's, which serves the +/// same routes; not against a live server. Failures throw , +/// the one type the code tools report in a sentence. +/// +/// +public sealed class GiteaService( + IHttpClientFactory httpFactory, + IConnectionStore connections, + ILogger log) : ICodeProvider +{ + public ProviderKind Provider => ProviderKind.Gitea; + + /// Gitea's default MAX_RESPONSE_ITEMS. Asking for more is silently clamped to it. + private const int PageLimit = 50; + + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private async Task ClientAsync(Connection c, CancellationToken ct) + { + var token = await connections.GetCredentialAsync(c, ct) + ?? throw new GitHubException($"{c.Label} has no stored credential. Reconnect it."); + + var http = GiteaConnectionService.Configure(httpFactory.CreateClient("gitea"), c.Instance, token); + await connections.TouchAsync(c.Id, ct); + return http; + } + + /// The account the token belongs to, recorded when it was connected. + private static string Me(Connection c) => c.AccountIdentifier ?? c.HomeAccountId ?? ""; + + private static string Host(Connection c) => + Uri.TryCreate(c.Instance, UriKind.Absolute, out var u) ? u.Host : c.Label; + + private static async Task ReadAsync(HttpResponseMessage resp, CancellationToken ct) where T : class, new() + { + await using var body = await resp.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(body, Json, ct) ?? new T(); + } + + private async Task GetOkAsync( + HttpClient http, Connection c, string url, string what, CancellationToken ct) + { + var resp = await http.GetAsync(url, ct); + if (resp.IsSuccessStatusCode) return resp; + + var status = (int)resp.StatusCode; + resp.Dispose(); + + throw new GitHubException(resp.StatusCode switch + { + HttpStatusCode.Unauthorized => $"{Host(c)} rejected the token for {c.Label}. Replace it on the connections page.", + HttpStatusCode.Forbidden => $"{Host(c)} refused {what}: the token for {c.Label} lacks the scope for it.", + _ => $"{Host(c)} returned HTTP {status} for {what}.", + }); + } + + // ── search: the flags that stand in for GitHub's query language ───────── + + private static string SearchUrl(string type, string flag, int limit, string? owner = null, int page = 1) => + $"repos/issues/search?state=open&type={type}&{flag}=true&limit={Math.Clamp(limit, 1, PageLimit)}&page={page}" + + (owner is null ? "" : $"&owner={Uri.EscapeDataString(owner)}"); + + private async Task CountAsync(HttpClient http, Connection c, string type, string flag, CancellationToken ct) + { + using var resp = await GetOkAsync(http, c, SearchUrl(type, flag, 1), "a search", ct); + + // The count is a header. A server or proxy that drops it leaves the page itself, + // which with limit=1 can only say "none" or "at least one" — said as 0 or 1 rather + // than guessed. + if (resp.Headers.TryGetValues("X-Total-Count", out var v) && int.TryParse(v.FirstOrDefault(), out var n)) + return n; + + return (await ReadAsync>(resp, ct)).Count; + } + + public async Task TriageAsync(Connection c, CancellationToken ct = default) + { + using var http = await ClientAsync(c, ct); + + var awaiting = await CountAsync(http, c, "pulls", "review_requested", ct); + var assigned = await CountAsync(http, c, "issues", "assigned", ct); + var mentions = await CountAsync(http, c, "pulls", "mentioned", ct) + + await CountAsync(http, c, "issues", "mentioned", ct); + + // Drafts are not a search filter, so "mine" is fetched rather than counted and the + // drafts are told apart here. One page; beyond fifty open PRs the total still comes + // from the header, and only the draft count is a floor. + int mine, drafts; + using (var resp = await GetOkAsync(http, c, SearchUrl("pulls", "created", PageLimit), "your pull requests", ct)) + { + var page = await ReadAsync>(resp, ct); + drafts = page.Count(IsDraft); + mine = resp.Headers.TryGetValues("X-Total-Count", out var v) && int.TryParse(v.FirstOrDefault(), out var n) + ? n : page.Count; + } + + log.LogInformation("Gitea triage for {Label}: {Awaiting} to review, {Mine} mine, {Assigned} issues.", + c.Label, awaiting, mine, assigned); + + return new CodeTriage + { + AwaitingMyReview = awaiting, + MyOpenPullRequests = mine, + MyDraftPullRequests = drafts, + IssuesAssignedToMe = assigned, + MentioningMe = mentions, + Login = Me(c) is { Length: > 0 } login ? login : "you", + }; + } + + public async Task> ListPullRequestsAsync( + Connection c, PullRequestScope scope, string? repo, int count, CancellationToken ct = default) + { + if (scope == PullRequestScope.AllOpen && string.IsNullOrWhiteSpace(repo)) + throw new GitHubException("Name a repository, or ask for yours or ones awaiting your review."); + + using var http = await ClientAsync(c, ct); + + if (scope == PullRequestScope.AllOpen) + return await RepoIssuesAsync(http, c, repo!, "pulls", count, unassignedOnly: false, ct); + + var flag = scope switch + { + PullRequestScope.AwaitingMyReview => "review_requested", + PullRequestScope.Mine => "created", + _ => "mentioned", + }; + + return await SearchItemsAsync(http, c, "pulls", flag, repo, count, ct); + } + + public async Task> ListIssuesAsync( + Connection c, IssueScope scope, string? repo, int count, CancellationToken ct = default) + { + if (scope is IssueScope.Unassigned or IssueScope.AllOpen && string.IsNullOrWhiteSpace(repo)) + throw new GitHubException( + "Name a repository for that — unassigned and all-open searches aren't limited to you."); + + using var http = await ClientAsync(c, ct); + + return scope switch + { + IssueScope.AssignedToMe => await SearchItemsAsync(http, c, "issues", "assigned", repo, count, ct), + IssueScope.Mentioning => await SearchItemsAsync(http, c, "issues", "mentioned", repo, count, ct), + _ => await RepoIssuesAsync(http, c, repo!, "issues", + count, unassignedOnly: scope == IssueScope.Unassigned, ct), + }; + } + + /// + /// A personal search ("mine", "awaiting my review"), optionally for one repository. + /// The endpoint filters by owner but not by repository, so a repository is asked for + /// by its owner and matched by full name here — paging on until enough are found or + /// the owner's results run out, rather than returning a short page that reads as "that + /// is all of them". + /// + private async Task> SearchItemsAsync( + HttpClient http, Connection c, string type, string flag, string? repo, int count, CancellationToken ct) + { + var wanted = Math.Max(count, 1); + var target = string.IsNullOrWhiteSpace(repo) ? null : repo.Trim().Trim('/'); + var owner = target?.Split('/')[0]; + + var found = new List(); + for (var page = 1; page <= 5 && found.Count < wanted; page++) + { + using var resp = await GetOkAsync(http, c, + SearchUrl(type, flag, target is null ? wanted : PageLimit, owner, page), "a search", ct); + var batch = await ReadAsync>(resp, ct); + + found.AddRange(batch.Select(i => Map(i, null)) + .Where(i => target is null || string.Equals(i.Repo, target, StringComparison.OrdinalIgnoreCase))); + + if (target is null || batch.Count < PageLimit) break; + } + + return found.Take(wanted).ToList(); + } + + /// Every open issue or pull request in one repository, newest activity first. + private async Task> RepoIssuesAsync( + HttpClient http, Connection c, string repo, string type, int count, bool unassignedOnly, CancellationToken ct) + { + var name = repo.Trim().Trim('/'); + var wanted = Math.Max(count, 1); + var found = new List(); + + for (var page = 1; page <= 5 && found.Count < wanted; page++) + { + using var resp = await http.GetAsync( + $"repos/{name}/issues?state=open&type={type}&limit={PageLimit}&page={page}", ct); + + if (resp.StatusCode == HttpStatusCode.NotFound) + throw new GitHubException($"No repository {name} on {Host(c)}, or this token cannot see it."); + if (!resp.IsSuccessStatusCode) + throw new GitHubException($"{Host(c)} returned HTTP {(int)resp.StatusCode} listing {type} on {name}."); + + var batch = await ReadAsync>(resp, ct); + found.AddRange(batch + .Where(i => !unassignedOnly || (i.Assignees is null or { Count: 0 } && i.Assignee is null)) + .Select(i => Map(i, name))); + + if (batch.Count < PageLimit) break; + } + + return found.OrderByDescending(i => i.UpdatedAt).Take(wanted).ToList(); + } + + // ── repositories and commits ──────────────────────────────────────────── + + public async Task> ListRepositoriesAsync(Connection c, CancellationToken ct = default) + { + using var http = await ClientAsync(c, ct); + var all = new List(); + + // The token's own repositories, its collaborations and its organisations'. Ten + // pages of fifty is the same five-hundred ceiling the GitHub side uses. + for (var page = 1; page <= 10; page++) + { + using var resp = await GetOkAsync(http, c, $"user/repos?limit={PageLimit}&page={page}", "your repositories", ct); + var batch = await ReadAsync>(resp, ct); + + all.AddRange(batch.Select(r => new RepoRef + { + FullName = r.FullName ?? "", + Name = r.Name ?? "", + Owner = r.Owner?.Login ?? "", + Description = r.Description, + Private = r.Private, + Fork = r.Fork, + Archived = r.Archived, + DefaultBranch = r.DefaultBranch, + + // Gitea reports no separate push time; updated_at moves on a push. + PushedAt = r.UpdatedAt, + Url = r.HtmlUrl, + })); + + if (batch.Count < PageLimit) break; + } + + log.LogInformation("Listed {Count} repositories for {Label}.", all.Count, c.Label); + return all.Where(r => r.FullName.Length > 0) + .OrderByDescending(r => r.PushedAt ?? DateTimeOffset.MinValue) + .ToList(); + } + + public async Task> ListCommitsAsync( + Connection c, string repo, string? branch, int count, CancellationToken ct = default) + { + using var http = await ClientAsync(c, ct); + var name = repo.Trim('/'); + + // stat, verification and files are each an extra cost per commit on the server + // and nothing here reads them. + var url = $"repos/{name}/commits?limit={Math.Clamp(count, 1, PageLimit)}&stat=false&verification=false&files=false" + + (string.IsNullOrWhiteSpace(branch) ? "" : $"&sha={Uri.EscapeDataString(branch.Trim())}"); + + using var resp = await http.GetAsync(url, ct); + + // An empty repository is 409, as on GitHub: a real answer, not a failure. + if (resp.StatusCode == HttpStatusCode.Conflict) return []; + + if (resp.StatusCode == HttpStatusCode.NotFound) + throw new GitHubException(string.IsNullOrWhiteSpace(branch) + ? $"No repository {name} on {Host(c)}, or this token cannot see it." + : $"No branch '{branch}' on {name}."); + + if (!resp.IsSuccessStatusCode) + throw new GitHubException($"{Host(c)} returned HTTP {(int)resp.StatusCode} for commits on {name}."); + + var commits = await ReadAsync>(resp, ct); + return commits.Select(x => new CommitRef + { + Sha = x.Sha ?? "", + Message = x.Commit?.Message ?? "(no message)", + Author = x.Author?.Login is { Length: > 0 } login ? login : x.Commit?.Author?.Name ?? "unknown", + At = x.Commit?.Author?.Date, + Url = x.HtmlUrl, + }).ToList(); + } + + public async Task GetAsync(Connection c, string repo, int number, CancellationToken ct = default) + { + using var http = await ClientAsync(c, ct); + var name = repo.Trim('/'); + + // As on GitHub, the issues endpoint answers for pull requests too. + using var resp = await http.GetAsync($"repos/{name}/issues/{number}", ct); + if (resp.StatusCode == HttpStatusCode.NotFound) return null; + if (!resp.IsSuccessStatusCode) + throw new GitHubException($"{Host(c)} returned HTTP {(int)resp.StatusCode} for {name}#{number}."); + + return Map(await ReadAsync(resp, ct), name); + } + + // ── contents ──────────────────────────────────────────────────────────── + + private static string EncodePath(string? path) => + string.IsNullOrWhiteSpace(path) + ? "" + : string.Join('/', path.Trim().Trim('/').Split('/') + .Where(seg => seg.Length > 0) + .Select(Uri.EscapeDataString)); + + private static string RefQuery(string? gitRef) => + string.IsNullOrWhiteSpace(gitRef) ? "" : $"?ref={Uri.EscapeDataString(gitRef.Trim())}"; + + private static async Task ContentsAsync( + HttpClient http, Connection c, string repo, string? path, string? gitRef, CancellationToken ct) + { + var encoded = EncodePath(path); + + // The root is "contents" with nothing after it; "contents/" is a different route + // that some versions answer with 404. + var url = $"repos/{repo.Trim('/')}/contents" + (encoded.Length == 0 ? "" : "/" + encoded) + RefQuery(gitRef); + + using var resp = await http.GetAsync(url, ct); + + if (resp.StatusCode == HttpStatusCode.NotFound) + { + var where = string.IsNullOrWhiteSpace(path) ? repo : $"{path} in {repo}"; + throw new GitHubException( + $"Nothing at {where}" + + (string.IsNullOrWhiteSpace(gitRef) ? "" : $" on {gitRef}") + + ". Either the path is wrong, the branch is wrong, or this token cannot see the repository."); + } + + if (!resp.IsSuccessStatusCode) + throw new GitHubException($"{Host(c)} returned HTTP {(int)resp.StatusCode} for contents of {repo}."); + + using var doc = await JsonDocument.ParseAsync(await resp.Content.ReadAsStreamAsync(ct), cancellationToken: ct); + return doc.RootElement.Clone(); + } + + public async Task?> ListPathAsync( + Connection c, string repo, string? path, string? gitRef, CancellationToken ct = default) + { + using var http = await ClientAsync(c, ct); + var root = await ContentsAsync(http, c, repo, path, gitRef, ct); + + if (root.ValueKind != JsonValueKind.Array) return null; + + return root.EnumerateArray().Select(e => new RepoEntry + { + Name = e.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "", + Path = e.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "", + IsDirectory = e.TryGetProperty("type", out var t) && t.GetString() == "dir", + Size = e.TryGetProperty("size", out var s) && s.TryGetInt64(out var bytes) ? bytes : 0, + }) + .OrderByDescending(e => e.IsDirectory) + .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task ReadFileAsync( + Connection c, string repo, string path, string? gitRef, int maxChars, CancellationToken ct = default) + { + using var http = await ClientAsync(c, ct); + var root = await ContentsAsync(http, c, repo, path, gitRef, ct); + + if (root.ValueKind == JsonValueKind.Array) return null; + + var type = root.TryGetProperty("type", out var t) ? t.GetString() : null; + if (type is "dir") return null; + if (type is "submodule") + throw new GitHubException($"{path} is a submodule — its contents live in another repository."); + if (type is "symlink") + throw new GitHubException($"{path} is a symlink, not a file."); + + var size = root.TryGetProperty("size", out var s) && s.TryGetInt64(out var bytes) ? bytes : 0; + var url = root.TryGetProperty("html_url", out var h) ? h.GetString() : null; + var encoded = root.TryGetProperty("content", out var ce) && ce.ValueKind == JsonValueKind.String + ? ce.GetString() : null; + + // Gitea inlines base64 up to its API content limit and sends null above it; the + // raw route serves the bytes either way. + var raw = string.IsNullOrWhiteSpace(encoded) + ? await RawAsync(http, c, repo, path, gitRef, ct) + : Convert.FromBase64String(encoded.Replace("\n", "").Replace("\r", "")); + + if (raw.AsSpan(0, Math.Min(raw.Length, 8000)).IndexOf((byte)0) >= 0) + throw new GitHubException( + $"{path} is a binary file ({ByteSize.Describe(size)}), so there is no text to read."); + + var text = Encoding.UTF8.GetString(raw); + var truncated = text.Length > maxChars; + + return new RepoFile + { + Path = path, + Text = truncated ? text[..maxChars] : text, + Size = size, + Truncated = truncated, + Url = url, + }; + } + + private static async Task RawAsync( + HttpClient http, Connection c, string repo, string path, string? gitRef, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, + $"repos/{repo.Trim('/')}/raw/{EncodePath(path)}" + RefQuery(gitRef)); + req.Headers.Accept.Clear(); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream")); + + using var resp = await http.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) + throw new GitHubException($"{Host(c)} returned HTTP {(int)resp.StatusCode} reading {path} in {repo}."); + + return await resp.Content.ReadAsByteArrayAsync(ct); + } + + // ── mapping ───────────────────────────────────────────────────────────── + + /// + /// A pull request is a draft by its flag where the server has one, and by Gitea's own + /// "WIP:" title convention everywhere — which servers before the flag used exclusively. + /// + private static bool IsDraft(Issue i) => + i.PullRequest is not null + && (i.PullRequest.Draft + || (i.Title ?? "").TrimStart().StartsWith("WIP:", StringComparison.OrdinalIgnoreCase) + || (i.Title ?? "").TrimStart().StartsWith("[WIP]", StringComparison.OrdinalIgnoreCase)); + + private static CodeItem Map(Issue i, string? repo) => new() + { + Repo = i.Repository?.FullName is { Length: > 0 } full ? full : repo ?? "unknown", + Number = i.Number, + Title = i.Title ?? "(no title)", + Author = i.User?.Login ?? "unknown", + UpdatedAt = i.UpdatedAt, + IsPullRequest = i.PullRequest is not null, + IsDraft = IsDraft(i), + Comments = i.Comments, + Labels = i.Labels?.Select(l => l.Name).Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!).ToList() ?? [], + Assignees = (i.Assignees ?? (i.Assignee is null ? [] : [i.Assignee])) + .Select(a => a.Login).Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!).ToList(), + Url = i.HtmlUrl, + }; + + // ── wire shapes ───────────────────────────────────────────────────────── + + private sealed record Issue + { + [JsonPropertyName("number")] public int Number { get; init; } + [JsonPropertyName("title")] public string? Title { get; init; } + [JsonPropertyName("user")] public Actor? User { get; init; } + [JsonPropertyName("updated_at")] public DateTimeOffset UpdatedAt { get; init; } + [JsonPropertyName("comments")] public int Comments { get; init; } + [JsonPropertyName("html_url")] public string? HtmlUrl { get; init; } + [JsonPropertyName("labels")] public List