Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 4 additions & 3 deletions src/OutPost2.Core/Connections/CapabilityCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
};

Expand All @@ -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(),
};
Expand All @@ -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.",
_ =>
Expand Down
8 changes: 8 additions & 0 deletions src/OutPost2.Core/Connections/Connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ public enum ProviderKind
/// so adding it changes no column and needs no migration.
/// </summary>
Google,

/// <summary>
/// A self-hosted Gitea or Forgejo server (OP2-84). One provider covers both: Forgejo is
/// a Gitea fork serving the same <c>/api/v1</c>. 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.
/// </summary>
Gitea,
}

public enum AuthKind
Expand Down
74 changes: 74 additions & 0 deletions src/OutPost2.Infrastructure/Code/CodeProviders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using OutPost2.Core.Connections;

namespace OutPost2.Infrastructure.Code;

/// <summary>
/// One code host's implementation of <see cref="IGitHubService"/> (OP2-84), the way mail
/// got <c>IMailProvider</c>. 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.
/// </summary>
public interface ICodeProvider : IGitHubService
{
ProviderKind Provider { get; }
}

/// <summary>
/// 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.
/// </summary>
public sealed class CodeServiceRouter(IEnumerable<ICodeProvider> providers) : IGitHubService
{
private readonly Dictionary<ProviderKind, ICodeProvider> _by = Index(providers);

private static Dictionary<ProviderKind, ICodeProvider> Index(IEnumerable<ICodeProvider> providers)
{
var by = new Dictionary<ProviderKind, ICodeProvider>();
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);

/// <summary>
/// A connection of a kind with no code implementation is a plain sentence, not a call
/// to GitHub with somebody's Gitea token.
/// </summary>
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<CodeTriage> TriageAsync(Connection c, CancellationToken ct = default) =>
For(c).TriageAsync(c, ct);

public Task<IReadOnlyList<RepoRef>> ListRepositoriesAsync(Connection c, CancellationToken ct = default) =>
For(c).ListRepositoriesAsync(c, ct);

public Task<IReadOnlyList<CommitRef>> ListCommitsAsync(
Connection c, string repo, string? branch, int count, CancellationToken ct = default) =>
For(c).ListCommitsAsync(c, repo, branch, count, ct);

public Task<IReadOnlyList<CodeItem>> ListPullRequestsAsync(
Connection c, PullRequestScope scope, string? repo, int count, CancellationToken ct = default) =>
For(c).ListPullRequestsAsync(c, scope, repo, count, ct);

public Task<IReadOnlyList<CodeItem>> ListIssuesAsync(
Connection c, IssueScope scope, string? repo, int count, CancellationToken ct = default) =>
For(c).ListIssuesAsync(c, scope, repo, count, ct);

public Task<CodeItem?> GetAsync(Connection c, string repo, int number, CancellationToken ct = default) =>
For(c).GetAsync(c, repo, number, ct);

public Task<IReadOnlyList<RepoEntry>?> ListPathAsync(
Connection c, string repo, string? path, string? gitRef, CancellationToken ct = default) =>
For(c).ListPathAsync(c, repo, path, gitRef, ct);

public Task<RepoFile?> ReadFileAsync(
Connection c, string repo, string path, string? gitRef, int maxChars, CancellationToken ct = default) =>
For(c).ReadFileAsync(c, repo, path, gitRef, maxChars, ct);
}
9 changes: 8 additions & 1 deletion src/OutPost2.Infrastructure/Code/GitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,10 @@ Task<IReadOnlyList<CodeItem>> ListIssuesAsync(
public sealed class GitHubService(
IHttpClientFactory httpFactory,
IConnectionStore connections,
ILogger<GitHubService> log) : IGitHubService
ILogger<GitHubService> log) : ICodeProvider
{
public ProviderKind Provider => ProviderKind.GitHub;

private async Task<HttpClient> ClientAsync(Connection c, CancellationToken ct)
{
var token = await connections.GetCredentialAsync(c, ct)
Expand Down Expand Up @@ -604,4 +606,9 @@ private sealed record CommitSignature
}
}

/// <summary>
/// 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.
/// </summary>
public sealed class GitHubException(string message) : Exception(message);
Loading
Loading