mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-04 07:27:11 +00:00
Merge 2bf8f6fb84 into 3c16575987
This commit is contained in:
commit
9e2fabe931
82 changed files with 2761 additions and 607 deletions
|
|
@ -25,6 +25,31 @@ describe("automation workflow source form values", () => {
|
|||
expect(workflowSourceFromFormValues(values)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("create-from-run reads the repository from origins on the configured GitHub host", () => {
|
||||
const fromOrigin = (originUrl: string, githubWebUrl?: string) =>
|
||||
automationFormValuesFromRun(
|
||||
{
|
||||
title: "Release",
|
||||
workflow: { name: "Release", graph_name: "release", slug: "release" },
|
||||
repository: { name: "", origin_url: originUrl },
|
||||
sandbox: null,
|
||||
} as any,
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
githubWebUrl,
|
||||
).targetRepository;
|
||||
|
||||
expect(fromOrigin("https://github.com/acme/widgets.git")).toBe("acme/widgets");
|
||||
expect(fromOrigin("git@github.com:acme/widgets.git")).toBe("acme/widgets");
|
||||
expect(fromOrigin("https://ghe.example.com/acme/widgets.git")).toBe("");
|
||||
|
||||
const ghes = "https://ghe.example.com";
|
||||
expect(fromOrigin("https://ghe.example.com/acme/widgets.git", ghes)).toBe("acme/widgets");
|
||||
expect(fromOrigin("git@ghe.example.com:acme/widgets.git", ghes)).toBe("acme/widgets");
|
||||
expect(fromOrigin("https://github.com/acme/widgets.git", ghes)).toBe("");
|
||||
});
|
||||
|
||||
test("branch, tag, and SHA selectors serialize with target precedence", () => {
|
||||
const base = {
|
||||
...EMPTY_AUTOMATION_FORM,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export function automationFormValuesFromRun(
|
|||
runState?: RunProjection | null,
|
||||
settings?: WorkflowSettings | null,
|
||||
environments?: Environment[],
|
||||
githubWebUrl: string = DEFAULT_GITHUB_WEB_URL,
|
||||
): AutomationFormValues {
|
||||
const name = firstPresentString(
|
||||
run.title,
|
||||
|
|
@ -117,7 +118,7 @@ export function automationFormValuesFromRun(
|
|||
const targetRepository = canonicalTarget?.repo
|
||||
?? githubRepositoryFromSettings(settings)
|
||||
?? githubRepositoryName(run.repository?.name)
|
||||
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
|
||||
?? githubRepositoryFromOriginUrl(run.repository?.origin_url, githubWebUrl)
|
||||
?? "";
|
||||
const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch;
|
||||
const sourceEnvironment = settings?.run?.environment;
|
||||
|
|
@ -245,20 +246,30 @@ function githubRepositoryName(value: string | null | undefined): string | null {
|
|||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null {
|
||||
const DEFAULT_GITHUB_WEB_URL = "https://github.com";
|
||||
|
||||
function githubRepositoryFromOriginUrl(
|
||||
value: string | null | undefined,
|
||||
githubWebUrl: string,
|
||||
): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const scpLikeMatch = trimmed.match(
|
||||
/^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i,
|
||||
);
|
||||
if (scpLikeMatch) {
|
||||
return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`);
|
||||
let githubHostname: string;
|
||||
try {
|
||||
githubHostname = new URL(githubWebUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scpPrefix = `git@${githubHostname}:`;
|
||||
if (trimmed.toLowerCase().startsWith(scpPrefix)) {
|
||||
return githubRepositoryName(trimmed.slice(scpPrefix.length));
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname.toLowerCase() !== "github.com") return null;
|
||||
if (url.hostname.toLowerCase() !== githubHostname) return null;
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length !== 2) return null;
|
||||
return githubRepositoryName(`${parts[0]}/${parts[1]}`);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const INSTALL_ERROR_MESSAGE =
|
|||
const INSTALL_PREFILL = {
|
||||
canonical_url: "https://fabro.example.com",
|
||||
object_store_local_root: "/home/test/.fabro/storage/objects",
|
||||
github_web_url: "https://github.com",
|
||||
};
|
||||
|
||||
const SESSION_RESPONSE = {
|
||||
|
|
@ -243,6 +244,67 @@ describe("InstallApp", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["https://github.com", "github.com/settings/tokens"],
|
||||
["https://ghe.example.com", "ghe.example.com/settings/tokens"],
|
||||
])("links the GitHub token page on %s", async (githubWebUrl, label) => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
...SESSION_RESPONSE,
|
||||
prefill: { ...INSTALL_PREFILL, github_web_url: githubWebUrl },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
useInstallFetchMock(fetchMock as unknown as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/github");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = createInstallRenderer(
|
||||
<MemoryRouter initialEntries={["/install/github"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain("Connect GitHub");
|
||||
});
|
||||
const link = renderer!.root.findAll(
|
||||
(node) => node.type === "a" && node.props.href === `${githubWebUrl}/settings/tokens`,
|
||||
);
|
||||
expect(link.length).toBeGreaterThan(0);
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain(label);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("renders the GitHub App done screen after the manifest callback resolves", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
|
|
|
|||
|
|
@ -1148,6 +1148,7 @@ function GithubStep({
|
|||
runStepSubmit: RunStepSubmit;
|
||||
dispatchInstall: (action: InstallAction) => void;
|
||||
}) {
|
||||
const tokensUrl = `${session.prefill.github_web_url}/settings/tokens`;
|
||||
return (
|
||||
<StepPanel
|
||||
title="Connect GitHub"
|
||||
|
|
@ -1267,8 +1268,8 @@ function GithubStep({
|
|||
Create a fine-grained or classic token with{" "}
|
||||
<code className="font-mono text-fg-2">repo</code> scope.
|
||||
</p>
|
||||
<ExternalLink href="https://github.com/settings/tokens">
|
||||
github.com/settings/tokens
|
||||
<ExternalLink href={tokensUrl}>
|
||||
{tokensUrl.replace(/^https?:\/\//, "")}
|
||||
</ExternalLink>
|
||||
</HelpDisclosure>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ let currentRunStateLoading = false;
|
|||
let currentRunSettings: any = null;
|
||||
let currentEnvironments: any[] = [];
|
||||
let currentEnvironmentsError: unknown = null;
|
||||
let currentGithubWebUrl = "https://github.com";
|
||||
const queryCalls: Array<{ hook: string; id: string | undefined }> = [];
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
let teardownReactEnv: (() => void) | undefined;
|
||||
|
|
@ -67,6 +68,11 @@ mock.module("../lib/queries", () => ({
|
|||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useServerSettings: () => ({
|
||||
data: { server: { integrations: { github: { web_url: currentGithubWebUrl } } } },
|
||||
error: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useRunState: (id: string | undefined) => {
|
||||
queryCalls.push({ hook: "useRunState", id });
|
||||
return {
|
||||
|
|
@ -277,6 +283,7 @@ beforeEach(() => {
|
|||
currentRunState = null;
|
||||
currentRunStateLoading = false;
|
||||
currentRunSettings = null;
|
||||
currentGithubWebUrl = "https://github.com";
|
||||
currentEnvironments = [
|
||||
{ id: "default", provider: "docker" },
|
||||
{ id: "daytona-smoke", provider: "daytona" },
|
||||
|
|
@ -416,6 +423,21 @@ describe("AutomationsNew", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("run origin on the configured GitHub Enterprise host prefills the target repository", async () => {
|
||||
currentGithubWebUrl = "https://ghe.example.com";
|
||||
currentRun = makeRun({
|
||||
repository: {
|
||||
name: "",
|
||||
origin_url: "https://ghe.example.com/acme/widgets.git",
|
||||
provider: "github",
|
||||
},
|
||||
});
|
||||
|
||||
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
|
||||
|
||||
expect(fieldValue(renderer, "Run target repository")).toBe("acme/widgets");
|
||||
});
|
||||
|
||||
test("automationFormValuesFromRun kebab-cases the workflow name fallback", () => {
|
||||
const run = makeRun({
|
||||
workflow: {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ import type { Environment } from "@qltysh/fabro-api-client";
|
|||
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useEnvironments, useRun, useRunSettings, useRunState } from "../lib/queries";
|
||||
import {
|
||||
useEnvironments,
|
||||
useRun,
|
||||
useRunSettings,
|
||||
useRunState,
|
||||
useServerSettings,
|
||||
} from "../lib/queries";
|
||||
import {
|
||||
AutomationFormFields,
|
||||
EMPTY_AUTOMATION_FORM,
|
||||
|
|
@ -37,6 +43,7 @@ export default function AutomationsNew() {
|
|||
const runStateQuery = useRunState(fromRunId);
|
||||
const settingsQuery = useRunSettings(fromRunId);
|
||||
const environmentsQuery = useEnvironments();
|
||||
const serverSettingsQuery = useServerSettings();
|
||||
const environments = environmentsQuery.data?.data;
|
||||
const environmentsPending = environmentsQuery.isLoading && !environmentsQuery.data;
|
||||
const environmentsError = Boolean(environmentsQuery.error);
|
||||
|
|
@ -86,6 +93,7 @@ export default function AutomationsNew() {
|
|||
runStateQuery.data ?? null,
|
||||
settingsQuery.data ?? null,
|
||||
environments,
|
||||
serverSettingsQuery.data?.server.integrations.github.web_url,
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -303,8 +303,8 @@ When a field is not set, Fabro derives it from the run's GitHub credential:
|
|||
|
||||
| Credential | Name | Email |
|
||||
|---|---|---|
|
||||
| GitHub App (`strategy = "app"`) | `<slug>[bot]` | `<id>+<slug>[bot]@users.noreply.github.com` |
|
||||
| Token (`strategy = "token"`) | the token's user login | `<id>+<login>@users.noreply.github.com` |
|
||||
| GitHub App (`strategy = "app"`) | `<slug>[bot]` | `<id>+<slug>[bot]@users.noreply.github.com` (`users.noreply.<host>` on an enterprise host) |
|
||||
| Token (`strategy = "token"`) | the token's user login | `<id>+<login>@users.noreply.github.com` (`users.noreply.<host>` on an enterprise host) |
|
||||
| None | `Fabro` | `noreply@fabro.sh` |
|
||||
|
||||
Setting both `name` and `email` skips the credential lookup. Setting one field overlays it on the derived identity. A lookup failure for the selected credential fails the run at setup; Fabro never silently switches to another author. The resolved identity is recorded in the run's event stream as `git.identity.resolved` and in the run state as `git_identity`.
|
||||
|
|
@ -323,6 +323,15 @@ Configure GitHub integration auth. `strategy = "token"` is the default and uses
|
|||
strategy = "token"
|
||||
```
|
||||
|
||||
| Key | Description | Default |
|
||||
|---|---|---|
|
||||
| `strategy` | `token` or `app` | `token` |
|
||||
| `app_id`, `client_id`, `slug` | GitHub App identity (App mode) | none |
|
||||
| `web_url` | Web origin of the GitHub deployment, such as `https://ghe.example.com`; must be `https` (`http` only for loopback). Override with `GITHUB_WEB_URL`. Read at server startup: changing it needs a restart | `https://github.com` |
|
||||
| `api_url` | REST API base URL; may be plain `http` (for a proxy or mock). Override with `GITHUB_BASE_URL` | derived from `web_url` (`https://api.github.com`, `https://api.<host>` for `*.ghe.com`, `<web_url>/api/v3` otherwise) |
|
||||
|
||||
For GitHub Enterprise Server or GitHub Enterprise Cloud, set `web_url` (see [GitHub Enterprise Server and GitHub Enterprise Cloud](/integrations/github#github-enterprise-server-and-github-enterprise-cloud)). The CLI and the run worker read the host from the local `settings.toml` or `GITHUB_WEB_URL`. Put `web_url` in `settings.toml` before `fabro install`; the installer does not persist it from the environment.
|
||||
|
||||
For GitHub App mode, set `strategy = "app"` and include `app_id`, `client_id`, and `slug`. Webhook delivery is configured under `[server.integrations.github.webhooks]`:
|
||||
|
||||
```toml title="settings.toml"
|
||||
|
|
|
|||
|
|
@ -6638,12 +6638,17 @@ components:
|
|||
required:
|
||||
- canonical_url
|
||||
- object_store_local_root
|
||||
- github_web_url
|
||||
properties:
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
object_store_local_root:
|
||||
type: string
|
||||
github_web_url:
|
||||
type: string
|
||||
description: Web origin of the GitHub deployment the install targets, without a trailing slash.
|
||||
example: https://github.com
|
||||
|
||||
InstallLlmValidationResponse:
|
||||
description: Successful response from install-time LLM credential validation.
|
||||
|
|
@ -15672,7 +15677,17 @@ components:
|
|||
- client_id
|
||||
- slug
|
||||
- webhooks
|
||||
- web_url
|
||||
- api_url
|
||||
properties:
|
||||
web_url:
|
||||
type: string
|
||||
description: Web origin of the GitHub deployment, without a trailing slash.
|
||||
example: https://github.com
|
||||
api_url:
|
||||
type: string
|
||||
description: REST API base URL of the GitHub deployment, without a trailing slash.
|
||||
example: https://api.github.com
|
||||
enabled:
|
||||
type: boolean
|
||||
strategy:
|
||||
|
|
|
|||
22
docs/public/changelog/2026-10-25.mdx
Normal file
22
docs/public/changelog/2026-10-25.mdx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
title: "GitHub Enterprise Server and Enterprise Cloud hosts"
|
||||
date: "2026-10-25"
|
||||
---
|
||||
|
||||
Fabro can now target a GitHub Enterprise Server instance or a GitHub
|
||||
Enterprise Cloud tenant with data residency (`https://<subdomain>.ghe.com`)
|
||||
instead of github.com. Set the deployment's web origin once and Fabro derives
|
||||
the REST, GraphQL and OAuth URLs, clone URLs, pull request links, Git
|
||||
credential scoping and the sandbox `gh` configuration from it:
|
||||
|
||||
```toml
|
||||
[server.integrations.github]
|
||||
web_url = "https://ghe.example.com"
|
||||
```
|
||||
|
||||
`GITHUB_WEB_URL` overrides the setting, and the existing `GITHUB_BASE_URL`
|
||||
still overrides the REST API base. Without the new setting, github.com
|
||||
behavior is unchanged.
|
||||
|
||||
See [GitHub Enterprise Server and GitHub Enterprise Cloud](/integrations/github#github-enterprise-server-and-github-enterprise-cloud)
|
||||
for setup, custom certificate authorities and known limitations.
|
||||
|
|
@ -303,6 +303,13 @@
|
|||
"tab": "Changelog",
|
||||
"icon": "clock-rotate-left",
|
||||
"groups": [
|
||||
{
|
||||
"group": "October 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-10-25"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "September 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ slug = "fabro-a3f2"
|
|||
| `app_id` | Numeric GitHub App ID |
|
||||
| `client_id` | OAuth Client ID for the app |
|
||||
| `slug` | App slug, used for linking to the GitHub App settings page |
|
||||
| `web_url`, `api_url` | Optional. Target GitHub Enterprise Server or Enterprise Cloud instead of github.com; see [GitHub Enterprise Server and GitHub Enterprise Cloud](#github-enterprise-server-and-github-enterprise-cloud) |
|
||||
|
||||
### Server vault
|
||||
|
||||
|
|
@ -174,6 +175,75 @@ fabro install github
|
|||
|
||||
This walks through just the GitHub setup steps — choosing a strategy, registering an app, or storing a token — and restarts the server when done. Stale settings and secrets from the previous strategy are cleaned up automatically.
|
||||
|
||||
## GitHub Enterprise Server and GitHub Enterprise Cloud
|
||||
|
||||
Fabro talks to github.com by default. To use a GitHub Enterprise Server (GHES) instance, or a GitHub Enterprise Cloud tenant with data residency (`https://<subdomain>.ghe.com`), set the web origin of that deployment. Fabro derives every other URL from it.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[server.integrations.github]
|
||||
web_url = "https://ghe.example.com"
|
||||
# api_url = "https://ghe.example.com/api/v3" # optional override
|
||||
```
|
||||
|
||||
| Setting | Environment override | Description |
|
||||
|---|---|---|
|
||||
| `server.integrations.github.web_url` | `GITHUB_WEB_URL` | Web origin of the GitHub deployment, for example `https://ghe.example.com`. Defaults to `https://github.com`. Must be `https` and must not contain a path, query or fragment (plain `http` is accepted only for loopback hosts such as `localhost` and `127.0.0.1`). |
|
||||
| `server.integrations.github.api_url` | `GITHUB_BASE_URL` | REST API base URL. Optional: when unset it is derived from `web_url` as shown below. May be plain `http` on any host, for example to reach GitHub through a proxy or a mock; `web_url` must still be `https`. |
|
||||
|
||||
The environment variables win over `settings.toml`. `GITHUB_WEB_URL` alone re-derives the API URL from the new web origin; `GITHUB_BASE_URL` alone replaces only the API URL.
|
||||
|
||||
The server reads the host once at startup, so changing `web_url` or `api_url` takes effect only after a server restart.
|
||||
|
||||
### Derived URLs
|
||||
|
||||
| | github.com | GitHub Enterprise Cloud (`https://octocorp.ghe.com`) | GitHub Enterprise Server (`https://ghe.example.com`) |
|
||||
|---|---|---|---|
|
||||
| REST API | `https://api.github.com` | `https://api.octocorp.ghe.com` | `https://ghe.example.com/api/v3` |
|
||||
| GraphQL | `https://api.github.com/graphql` | `https://api.octocorp.ghe.com/graphql` | `https://ghe.example.com/api/graphql` |
|
||||
| OAuth | `https://github.com/login/oauth/...` | `https://octocorp.ghe.com/login/oauth/...` | `https://ghe.example.com/login/oauth/...` |
|
||||
| Repository clone URL | `https://github.com/owner/repo` | `https://octocorp.ghe.com/owner/repo` | `https://ghe.example.com/owner/repo` |
|
||||
| Pull request link | `https://github.com/owner/repo/pull/7` | `https://octocorp.ghe.com/owner/repo/pull/7` | `https://ghe.example.com/owner/repo/pull/7` |
|
||||
| Commit email domain | `users.noreply.github.com` | `users.noreply.octocorp.ghe.com` | `users.noreply.ghe.example.com` |
|
||||
|
||||
A host named exactly `github.com` is treated as github.com, `ghe.com` or `*.ghe.com` as GitHub Enterprise Cloud, and any other host as GitHub Enterprise Server.
|
||||
|
||||
### Where the host is read
|
||||
|
||||
The server, the run worker and the CLI all need to agree on the host:
|
||||
|
||||
- The **server** reads `web_url` and `api_url` from its `settings.toml` and the environment overrides above. The server passes `GITHUB_WEB_URL` and `GITHUB_BASE_URL` on to run workers it starts.
|
||||
- The **run worker** reads the same `settings.toml` (through `FABRO_HOME`) and the same environment variables.
|
||||
- The **CLI** reads the host from the local `settings.toml` or from `GITHUB_WEB_URL`; it does not ask a remote server. If you run `fabro` against a remote server, set the same `web_url` in your local `settings.toml` (or export `GITHUB_WEB_URL`) so repository URLs, `fabro install` and `fabro doctor` target the enterprise host.
|
||||
|
||||
Put `web_url` in `settings.toml` before running `fabro install`. The installer does not persist the host, so `GITHUB_WEB_URL` alone is not enough: a server restarted without that variable falls back to github.com. The installer uses it for `gh auth token --hostname <host>`, for the GitHub App manifest registration and for the links it prints. The `App:` link it prints points at `/github-apps/<slug>` on GitHub Enterprise Server and at `/apps/<slug>` on github.com and GitHub Enterprise Cloud.
|
||||
|
||||
### Authentication
|
||||
|
||||
- **Tokens.** Personal access tokens, fine-grained tokens and OAuth tokens use the same `ghp_`, `github_pat_`, `gho_`, `ghu_` and `ghs_` formats on GHES and GHEC. `fabro install` runs `gh auth token --hostname <host>`, so run `gh auth login --hostname ghe.example.com` first.
|
||||
- **GitHub App.** `fabro install` registers the App through the manifest flow against `<web_url>/settings/apps/new` (or the organization equivalent) and exchanges the code on the host's API. This needs a GHES version that supports GitHub App manifests. If your instance does not, register the App by hand and set `app_id`, `client_id` and `slug` under `[server.integrations.github]`, then store the private key and client secret as described in [Server vault](#server-vault).
|
||||
- **Browser sign-in.** OAuth login redirects to `<web_url>/login/oauth/authorize`, and sessions record the enterprise host as the identity issuer.
|
||||
|
||||
### Custom certificate authorities
|
||||
|
||||
Enterprise hosts often use a certificate from a private CA. Fabro always verifies TLS certificates and has no setting to disable verification.
|
||||
|
||||
Fabro's HTTP client uses rustls with the platform certificate verifier:
|
||||
|
||||
- **Linux:** `SSL_CERT_FILE` and `SSL_CERT_DIR` are honoured, in addition to the system trust store. The server passes both on to the run workers it starts.
|
||||
- **macOS:** the system Keychain is used. **Windows:** the Windows certificate store is used. The variables are not read on either platform, so install the CA in the system store.
|
||||
- **Sandboxes:** containers do not inherit the host's trust store. Bake the CA into the sandbox image, or set `SSL_CERT_FILE` and `GIT_SSL_CAINFO` in the sandbox environment so Git and the `gh` CLI trust it.
|
||||
|
||||
### The `gh` CLI in sandboxes
|
||||
|
||||
The `gh` CLI reads `GITHUB_TOKEN` only for github.com. For any other host Fabro also injects `GH_HOST=<host>` and `GH_ENTERPRISE_TOKEN=<token>` next to `GITHUB_TOKEN`, so `gh pr list` and similar commands in command stages work without `gh auth login`. Git's credential helper and the `git@<host>:` and `ssh://git@<host>/` rewrites are scoped to the configured host.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- One GitHub host per server. Repositories on any other host (including github.com when `web_url` points at an enterprise host) are rejected.
|
||||
- Features that an older GHES release does not provide, such as newer App permissions or auto-merge, surface as the error GitHub returns.
|
||||
- Avatars and the Content Security Policy follow the configured host: for a non-github.com host, `img-src` allows the web origin and `https://avatars.<host>` instead of `avatars.githubusercontent.com`. A GHES instance that serves avatars from another origin shows broken avatar images.
|
||||
- The Fabro CLI release channel and documentation links stay on github.com.
|
||||
|
||||
## `token` mode
|
||||
|
||||
Choose **GitHub CLI** in `fabro install` to use the default local-user flow. The installer:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::Result;
|
||||
use fabro_api::types as api_types;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_github::GitHubHost;
|
||||
use fabro_types::settings::replace_wildcard_host;
|
||||
pub(crate) use fabro_util::check_report::{
|
||||
CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus,
|
||||
|
|
@ -125,10 +126,14 @@ fn wildcard_public_url_details(path: &Path) -> Vec<CheckDetail> {
|
|||
.unwrap_or(&bad_urls[0])
|
||||
.suggestion
|
||||
.as_str();
|
||||
let github_host = toml_string_at(&doc, &["server", "integrations", "github", "web_url"])
|
||||
.and_then(|web_url| GitHubHost::new(web_url, None).ok())
|
||||
.and_then(|host| fabro_github::with_env_overrides(&host).ok())
|
||||
.unwrap_or_default();
|
||||
let github_settings_url = toml_string_at(&doc, &["server", "integrations", "github", "slug"])
|
||||
.map_or_else(
|
||||
|| "the GitHub App settings page".to_string(),
|
||||
|slug| format!("https://github.com/settings/apps/{slug}"),
|
||||
|slug| format!("{}/settings/apps/{slug}", github_host.web_url()),
|
||||
);
|
||||
details.push(CheckDetail {
|
||||
text: format!(
|
||||
|
|
@ -470,6 +475,40 @@ url = "http://0.0.0.0:32276"
|
|||
assert!(details.contains("https://github.com/settings/apps/octocat-fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "unit test stages a temporary settings.toml fixture with sync std::fs"
|
||||
)]
|
||||
fn check_config_links_the_github_app_on_the_enterprise_host() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.toml");
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.web]
|
||||
url = "http://0.0.0.0:32276"
|
||||
|
||||
[server.integrations.github]
|
||||
slug = "octocat-fabro"
|
||||
web_url = "https://ghe.example.com"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = check_config(Some(settings_path));
|
||||
let details = result
|
||||
.details
|
||||
.iter()
|
||||
.map(|detail| detail.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert!(details.contains("https://ghe.example.com/settings/apps/octocat-fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_version_parity_warns_on_mismatch() {
|
||||
let result = check_version_parity("0.0.0-test");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use crate::args::{GraphArgs, GraphDirection, GraphOutputFormat};
|
|||
use crate::command_context::CommandContext;
|
||||
use crate::commands::run::output::api_diagnostics_to_local;
|
||||
use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, relative_path};
|
||||
use crate::user_config;
|
||||
|
||||
pub(crate) async fn run(
|
||||
args: &GraphArgs,
|
||||
|
|
@ -39,6 +40,7 @@ pub(crate) async fn run(
|
|||
cwd: ctx.cwd().to_path_buf(),
|
||||
environment_defaults: fabro_environment::seeded_catalog_layer(),
|
||||
user_settings_path: Some(active_settings_path(None)),
|
||||
github_host: user_config::github_host()?,
|
||||
..Default::default()
|
||||
})?;
|
||||
let client = ctx.server().await?;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use fabro_config::bind::Bind;
|
|||
use fabro_config::daemon::ServerDaemon;
|
||||
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
|
||||
use fabro_config::{Storage, UserSettingsBuilder, envfile};
|
||||
use fabro_github::GitHubHost;
|
||||
use fabro_install::{
|
||||
GITHUB_APP_VAULT_KEYS, GITHUB_INSTALL_SECRET_KEYS, InstallListenConfig, InstallPersistencePlan,
|
||||
PendingDevTokenWrite, PendingSettingsWrite, SecretStoreWrite,
|
||||
|
|
@ -498,7 +499,8 @@ impl InstallInputSource for InteractiveInstallInputSource {
|
|||
s: &Styles,
|
||||
_printer: Printer,
|
||||
) -> Result<GitHubInstallSelection> {
|
||||
let gh_available = GhCli::detect().await.is_some();
|
||||
let github_host = user_config::github_host()?;
|
||||
let gh_available = GhCli::detect(&github_host).await.is_some();
|
||||
|
||||
let token_label = if gh_available {
|
||||
"Personal Access Token — use your existing `gh` login"
|
||||
|
|
@ -518,7 +520,7 @@ impl InstallInputSource for InteractiveInstallInputSource {
|
|||
match strategy {
|
||||
0 => {
|
||||
let token = if gh_available {
|
||||
fabro_github::gh_auth_token()
|
||||
fabro_github::gh_auth_token(&github_host)
|
||||
.await
|
||||
.context("Run `gh auth login` and rerun `fabro install`.")?
|
||||
} else {
|
||||
|
|
@ -528,7 +530,7 @@ impl InstallInputSource for InteractiveInstallInputSource {
|
|||
Ok(GitHubInstallSelection::Token { token })
|
||||
}
|
||||
1 => {
|
||||
let (owner, username) = prompt_github_app_owner(s).await?;
|
||||
let (owner, username) = prompt_github_app_owner(s, &github_host).await?;
|
||||
Ok(GitHubInstallSelection::App { owner, username })
|
||||
}
|
||||
_ => unreachable!("prompt_select returned an out-of-range index"),
|
||||
|
|
@ -694,9 +696,10 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
|
|||
_s: &Styles,
|
||||
_printer: Printer,
|
||||
) -> Result<GitHubInstallSelection> {
|
||||
let github_host = user_config::github_host()?;
|
||||
match self.args.github_strategy {
|
||||
Some(InstallGitHubStrategyArg::Token) => {
|
||||
let token = fabro_github::gh_auth_token()
|
||||
let token = fabro_github::gh_auth_token(&github_host)
|
||||
.await
|
||||
.context("Run `gh auth login` and rerun `fabro install`.")?;
|
||||
fabro_github::validate_static_github_token(&token)?;
|
||||
|
|
@ -708,7 +711,7 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
|
|||
"non-interactive install requires --github-owner for --github-strategy app",
|
||||
)?,
|
||||
)?,
|
||||
username: best_effort_github_username().await,
|
||||
username: best_effort_github_username(&github_host).await,
|
||||
}),
|
||||
None => bail!("non-interactive install requires --github-strategy"),
|
||||
}
|
||||
|
|
@ -780,9 +783,10 @@ async fn choose_install_github_selection(
|
|||
return input.choose_github_install(s, printer).await;
|
||||
}
|
||||
|
||||
let github_host = user_config::github_host()?;
|
||||
match github_args.strategy {
|
||||
Some(InstallGitHubStrategyArg::Token) => {
|
||||
let token = fabro_github::gh_auth_token()
|
||||
let token = fabro_github::gh_auth_token(&github_host)
|
||||
.await
|
||||
.context("Run `gh auth login` and rerun `fabro install github`.")?;
|
||||
fabro_github::validate_static_github_token(&token)?;
|
||||
|
|
@ -792,7 +796,7 @@ async fn choose_install_github_selection(
|
|||
owner: GitHubAppOwner::parse_scripted(github_args.owner.as_deref().context(
|
||||
"install github --non-interactive requires --owner for --strategy app",
|
||||
)?)?,
|
||||
username: best_effort_github_username().await,
|
||||
username: best_effort_github_username(&github_host).await,
|
||||
}),
|
||||
None => bail!("install github --non-interactive requires --strategy"),
|
||||
}
|
||||
|
|
@ -824,11 +828,12 @@ impl GitHubAppOwner {
|
|||
Ok(Self::Organization(org.to_string()))
|
||||
}
|
||||
|
||||
fn manifest_form_action(&self) -> String {
|
||||
fn manifest_form_action(&self, github_host: &GitHubHost) -> String {
|
||||
let web_url = github_host.web_url();
|
||||
match self {
|
||||
Self::Personal => "https://github.com/settings/apps/new".to_string(),
|
||||
Self::Personal => format!("{web_url}/settings/apps/new"),
|
||||
Self::Organization(org) => {
|
||||
format!("https://github.com/organizations/{org}/settings/apps/new")
|
||||
format!("{web_url}/organizations/{org}/settings/apps/new")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -860,8 +865,8 @@ impl GitHubAppOwner {
|
|||
}
|
||||
}
|
||||
|
||||
async fn best_effort_github_username() -> Option<String> {
|
||||
let gh = GhCli::detect().await?;
|
||||
async fn best_effort_github_username(github_host: &GitHubHost) -> Option<String> {
|
||||
let gh = GhCli::detect(github_host).await?;
|
||||
gh.authenticated_user().await
|
||||
}
|
||||
|
||||
|
|
@ -872,10 +877,13 @@ async fn best_effort_github_username() -> Option<String> {
|
|||
/// Always offers a manual "Other" option so org app managers can enter a slug.
|
||||
///
|
||||
/// Returns `(owner, username)`.
|
||||
async fn prompt_github_app_owner(_s: &Styles) -> Result<(GitHubAppOwner, Option<String>)> {
|
||||
async fn prompt_github_app_owner(
|
||||
_s: &Styles,
|
||||
github_host: &GitHubHost,
|
||||
) -> Result<(GitHubAppOwner, Option<String>)> {
|
||||
let spinner = cyan_spinner("Checking GitHub CLI...");
|
||||
|
||||
let Some(gh) = GhCli::detect().await else {
|
||||
let Some(gh) = GhCli::detect(github_host).await else {
|
||||
spinner.finish_and_clear();
|
||||
return Ok((GitHubAppOwner::Personal, None));
|
||||
};
|
||||
|
|
@ -974,6 +982,7 @@ async fn setup_github_app(
|
|||
printer: Printer,
|
||||
) -> Result<GitHubAppRegistration> {
|
||||
let app_name = owner.app_name(username);
|
||||
let github_host = user_config::github_host()?;
|
||||
|
||||
// Bind to random port
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
|
|
@ -999,7 +1008,7 @@ async fn setup_github_app(
|
|||
let code_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(code_tx)));
|
||||
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
|
||||
|
||||
let form_action = owner.manifest_form_action();
|
||||
let form_action = owner.manifest_form_action(&github_host);
|
||||
let index_html = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -1117,7 +1126,8 @@ async fn setup_github_app(
|
|||
let client = fabro_http::http_client()?;
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"https://api.github.com/app-manifests/{code}/conversions"
|
||||
"{}/app-manifests/{code}/conversions",
|
||||
github_host.api_url()
|
||||
))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "fabro-cli")
|
||||
|
|
@ -1159,7 +1169,7 @@ async fn setup_github_app(
|
|||
printer,
|
||||
" {}",
|
||||
s.dim
|
||||
.apply_to(format!("App: https://github.com/apps/{slug}"))
|
||||
.apply_to(format!("App: {}", github_host.app_page_url(&slug)))
|
||||
);
|
||||
|
||||
// Return secret pairs
|
||||
|
|
@ -2569,16 +2579,29 @@ client_id = "client-id"
|
|||
fn github_app_owner_personal_url() {
|
||||
let owner = GitHubAppOwner::Personal;
|
||||
assert_eq!(
|
||||
owner.manifest_form_action(),
|
||||
owner.manifest_form_action(&GitHubHost::default()),
|
||||
"https://github.com/settings/apps/new"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_app_owner_urls_follow_the_enterprise_host() {
|
||||
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
assert_eq!(
|
||||
GitHubAppOwner::Personal.manifest_form_action(&host),
|
||||
"https://ghe.example.com/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
GitHubAppOwner::Organization("my-org".to_string()).manifest_form_action(&host),
|
||||
"https://ghe.example.com/organizations/my-org/settings/apps/new"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_app_owner_org_url() {
|
||||
let owner = GitHubAppOwner::Organization("my-org".to_string());
|
||||
assert_eq!(
|
||||
owner.manifest_form_action(),
|
||||
owner.manifest_form_action(&GitHubHost::default()),
|
||||
"https://github.com/organizations/my-org/settings/apps/new"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::commands::run::output::{
|
|||
use crate::commands::run::overrides::preflight_args_overrides;
|
||||
use crate::manifest_args::preflight_manifest_args;
|
||||
use crate::shared::{cyan_spinner, print_json_pretty};
|
||||
use crate::user_config;
|
||||
|
||||
pub(crate) async fn execute(
|
||||
mut args: PreflightArgs,
|
||||
|
|
@ -32,6 +33,7 @@ pub(crate) async fn execute(
|
|||
args: preflight_manifest_args(&args),
|
||||
environment_defaults: fabro_environment::seeded_catalog_layer(),
|
||||
user_settings_path: Some(active_settings_path(None)),
|
||||
github_host: user_config::github_host()?,
|
||||
})?;
|
||||
|
||||
let spinner = (!ctx.json_output()).then(|| cyan_spinner("Running checks..."));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use tokio::task::spawn_blocking;
|
|||
|
||||
use crate::args::{RepoInitArgs, ServerTargetArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::user_config;
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
|
|
@ -193,8 +194,11 @@ async fn check_github_app_installation(target: &ServerTargetArgs, base_ctx: &Com
|
|||
};
|
||||
|
||||
// Convert SSH URL to HTTPS and parse owner/repo
|
||||
let Ok(host) = user_config::github_host() else {
|
||||
return; // Unreadable server settings — skip silently
|
||||
};
|
||||
let https_url = fabro_github::ssh_url_to_https(&remote_url);
|
||||
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&https_url) else {
|
||||
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&host, &https_url) else {
|
||||
return; // Not a GitHub repo — skip silently
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use super::{resolution, selection};
|
|||
use crate::args::RunArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::commands::resolve_run_id;
|
||||
use crate::user_config::{RunSettingsKeyPresence, read_project_run_settings_key_presence};
|
||||
use crate::user_config::{self, RunSettingsKeyPresence, read_project_run_settings_key_presence};
|
||||
|
||||
pub(crate) struct CreatedRun {
|
||||
pub(crate) run_id: RunId,
|
||||
|
|
@ -104,7 +104,10 @@ pub(crate) async fn create_run(
|
|||
&& args.target_repo.is_none()
|
||||
&& args.target_repo_selector.is_none() =>
|
||||
{
|
||||
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?
|
||||
fabro_manifest::configured_repo_origin_url_for_location(
|
||||
&user_config::github_host()?,
|
||||
package.workflow_location(),
|
||||
)?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ use tracing::{info, warn};
|
|||
use super::publish;
|
||||
use super::runner::{self, WorkerTitlePhase};
|
||||
use crate::args::RunWorkerMode;
|
||||
use crate::command_context;
|
||||
use crate::{command_context, user_config};
|
||||
|
||||
/// What the worker holds when it hands a run to Petri.
|
||||
pub(super) struct PetriWorker<'a> {
|
||||
|
|
@ -221,10 +221,17 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> {
|
|||
None
|
||||
}
|
||||
};
|
||||
let read_tokens = publish::read_token_source(&worker.run_state.spec, github.as_ref());
|
||||
runtime.stage_credentials =
|
||||
StageCredentials::for_run(&worker.run_state.spec, github.as_ref(), read_tokens.clone())?;
|
||||
let github_host = user_config::github_host()?;
|
||||
let read_tokens =
|
||||
publish::read_token_source(&worker.run_state.spec, github.as_ref(), &github_host);
|
||||
runtime.stage_credentials = StageCredentials::for_run(
|
||||
&worker.run_state.spec,
|
||||
github.as_ref(),
|
||||
&github_host,
|
||||
read_tokens.clone(),
|
||||
)?;
|
||||
let mut source = RunSource::for_run(
|
||||
&github_host,
|
||||
worker.run_state.spec.target.as_ref(),
|
||||
&worker.run_state.spec.settings.run,
|
||||
None,
|
||||
|
|
@ -236,6 +243,7 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> {
|
|||
run_id,
|
||||
&worker.run_state.spec,
|
||||
github,
|
||||
github_host,
|
||||
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
|
||||
Arc::new(catalog),
|
||||
Arc::clone(&records),
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ use fabro_static::EnvVars;
|
|||
use fabro_store::platform_records::{PlatformRecord, PullRequestCreatedRecord};
|
||||
use fabro_types::settings::run::{PullRequestSettings, RunMode};
|
||||
use fabro_types::settings::server::GithubIntegrationStrategy;
|
||||
use fabro_types::{GitHubRepositorySlug, RunId, RunSpec, RunTarget};
|
||||
use fabro_types::{GitHubHost, GitHubRepositorySlug, RunId, RunSpec, RunTarget};
|
||||
use fabro_vault::Vault;
|
||||
use fabro_workflow::pull_request::{self, AutoMergeOptions, OpenPullRequestRequest};
|
||||
use tokio::time;
|
||||
|
|
@ -77,11 +77,11 @@ pub(super) fn github_credentials(vault: &Vault) -> Result<Option<GitHubCredentia
|
|||
}
|
||||
|
||||
/// The run's GitHub repository, when its target names one.
|
||||
fn repository(spec: &RunSpec) -> Option<GitHubRepositorySlug> {
|
||||
fn repository(spec: &RunSpec, host: &GitHubHost) -> Option<GitHubRepositorySlug> {
|
||||
let Some(RunTarget::Git(target)) = spec.target.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
Some(target.clone().validate().ok()?.repository().clone())
|
||||
Some(target.clone().validate(host).ok()?.repository().clone())
|
||||
}
|
||||
|
||||
/// A cached token source for the run's repository with `permissions`, or
|
||||
|
|
@ -89,12 +89,14 @@ fn repository(spec: &RunSpec) -> Option<GitHubRepositorySlug> {
|
|||
fn token_source(
|
||||
spec: &RunSpec,
|
||||
credentials: Option<&GitHubCredentials>,
|
||||
host: &GitHubHost,
|
||||
permissions: serde_json::Value,
|
||||
) -> Option<Arc<InstallationTokenSource>> {
|
||||
let repository = repository(spec)?;
|
||||
let repository = repository(spec, host)?;
|
||||
let credentials = credentials?;
|
||||
match InstallationTokenSource::for_repository(
|
||||
credentials,
|
||||
host,
|
||||
repository.owner().to_string(),
|
||||
repository.repo().to_string(),
|
||||
permissions,
|
||||
|
|
@ -114,8 +116,14 @@ fn token_source(
|
|||
pub(super) fn read_token_source(
|
||||
spec: &RunSpec,
|
||||
credentials: Option<&GitHubCredentials>,
|
||||
host: &GitHubHost,
|
||||
) -> Option<Arc<InstallationTokenSource>> {
|
||||
token_source(spec, credentials, serde_json::json!({ "contents": "read" }))
|
||||
token_source(
|
||||
spec,
|
||||
credentials,
|
||||
host,
|
||||
serde_json::json!({ "contents": "read" }),
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetch credentials over the run's read-only token source.
|
||||
|
|
@ -160,6 +168,7 @@ pub(super) struct GitHubPublisher {
|
|||
/// The run's model, when its settings name one.
|
||||
model: Option<String>,
|
||||
credentials: Option<GitHubCredentials>,
|
||||
host: GitHubHost,
|
||||
/// The run's `contents: write` token source; `None` without credentials.
|
||||
push_tokens: Option<Arc<InstallationTokenSource>>,
|
||||
pull_request: Option<PullRequestSettings>,
|
||||
|
|
@ -176,6 +185,7 @@ impl GitHubPublisher {
|
|||
run_id: RunId,
|
||||
spec: &RunSpec,
|
||||
credentials: Option<GitHubCredentials>,
|
||||
host: GitHubHost,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
records: Arc<dyn PlatformRecords>,
|
||||
|
|
@ -192,10 +202,11 @@ impl GitHubPublisher {
|
|||
let Some(RunTarget::Git(target)) = spec.target.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let repository = repository(spec)?;
|
||||
let repository = repository(spec, &host)?;
|
||||
let push_tokens = token_source(
|
||||
spec,
|
||||
credentials.as_ref(),
|
||||
&host,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
);
|
||||
Some(Self {
|
||||
|
|
@ -205,6 +216,7 @@ impl GitHubPublisher {
|
|||
goal: spec.graph.goal.clone(),
|
||||
model: settings.model.name.clone(),
|
||||
credentials,
|
||||
host,
|
||||
push_tokens,
|
||||
pull_request: settings
|
||||
.pull_request
|
||||
|
|
@ -243,7 +255,7 @@ impl GitHubPublisher {
|
|||
.ok_or_else(|| "no LLM model is available to write the pull request".to_string())?;
|
||||
// The run so far, for the pull request's details: best effort.
|
||||
let run_state = self.client.get_run_state(&self.run_id).await.ok();
|
||||
let origin_url = self.repository.https_url();
|
||||
let origin_url = self.repository.https_url(&self.host);
|
||||
let created = pull_request::open_pull_request(OpenPullRequestRequest {
|
||||
run_id: self.run_id,
|
||||
github: context,
|
||||
|
|
@ -293,7 +305,15 @@ impl RunPublisher for GitHubPublisher {
|
|||
.resolve()
|
||||
.await
|
||||
.map_err(|err| format!("no push credential for {}: {err:#}", self.repository))?;
|
||||
push(&self.repository, &resolved.token, site, branch, sha).await
|
||||
push(
|
||||
&self.repository,
|
||||
&self.host,
|
||||
&resolved.token,
|
||||
site,
|
||||
branch,
|
||||
sha,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn publish(&self, publication: &Publication) -> Result<(), String> {
|
||||
|
|
@ -313,8 +333,7 @@ impl RunPublisher for GitHubPublisher {
|
|||
.credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "GitHub credentials are unavailable".to_string())?;
|
||||
let base_url = fabro_github::github_api_base_url();
|
||||
let context = GitHubContext::new(credentials, &base_url);
|
||||
let context = GitHubContext::new(credentials, &self.host);
|
||||
Box::pin(self.open_pull_request(context, settings, publication)).await
|
||||
}
|
||||
}
|
||||
|
|
@ -325,12 +344,13 @@ impl RunPublisher for GitHubPublisher {
|
|||
/// repository alone and never appears in the error.
|
||||
async fn push(
|
||||
repository: &GitHubRepositorySlug,
|
||||
host: &GitHubHost,
|
||||
token: &SecretString,
|
||||
site: &Site,
|
||||
branch: &str,
|
||||
sha: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut url = repository.https_url();
|
||||
let mut url = repository.https_url(host);
|
||||
url.push_str(".git");
|
||||
let credential = basic(token);
|
||||
let env = credential
|
||||
|
|
@ -399,6 +419,7 @@ mod tests {
|
|||
RunId::new(),
|
||||
spec,
|
||||
None,
|
||||
GitHubHost::default(),
|
||||
Arc::new(NoCredentials),
|
||||
Arc::new(test_support::test_catalog()),
|
||||
Arc::new(MemoryPlatformRecords::new()),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use std::time::Duration;
|
|||
use anyhow::{Context as _, bail};
|
||||
use fabro_manifest::CollectedWorkflowClosure;
|
||||
use fabro_proc::ProcessError;
|
||||
use fabro_types::{GitHubRepositorySlug, GitRunTarget, repository};
|
||||
use fabro_types::{GitHubHost, GitHubRepositorySlug, GitRunTarget, repository};
|
||||
use tokio::process::Command;
|
||||
use tokio::{fs, signal as tokio_signal, task};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
|
@ -69,17 +69,19 @@ pub(super) enum RemoteWorkflowError {
|
|||
/// the same Git configuration: the user's global and system config applies,
|
||||
/// repository-local config from wherever the CLI was invoked does not.
|
||||
pub(super) struct NativeGit {
|
||||
host: GitHubHost,
|
||||
timeout: Duration,
|
||||
#[cfg(test)]
|
||||
pub(super) environment: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl NativeGit {
|
||||
pub(super) fn new() -> Self {
|
||||
pub(super) fn new(host: GitHubHost) -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_mins(2),
|
||||
host,
|
||||
timeout: Duration::from_mins(2),
|
||||
#[cfg(test)]
|
||||
environment: Vec::new(),
|
||||
environment: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +159,7 @@ impl NativeGit {
|
|||
patterns: &[String],
|
||||
cancel: &CancellationToken,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = repository.https_url();
|
||||
let url = repository.https_url(&self.host);
|
||||
let mut args = vec!["ls-remote", "--symref", &url];
|
||||
args.extend(patterns.iter().map(String::as_str));
|
||||
let bytes = self.command("metadata lookup", root, &args, cancel).await?;
|
||||
|
|
@ -193,7 +195,7 @@ impl NativeGit {
|
|||
tag: None,
|
||||
sha: Some(sha),
|
||||
};
|
||||
Ok(target.validate()?.into_target())
|
||||
Ok(target.validate(&self.host)?.into_target())
|
||||
}
|
||||
|
||||
/// Resolve `revision` to a commit SHA using metadata lookups run from
|
||||
|
|
@ -298,7 +300,7 @@ impl NativeGit {
|
|||
"--depth=1",
|
||||
"--no-tags",
|
||||
"--no-recurse-submodules",
|
||||
&repository.https_url(),
|
||||
&repository.https_url(&self.host),
|
||||
sha,
|
||||
],
|
||||
cancel,
|
||||
|
|
@ -570,7 +572,7 @@ mod tests {
|
|||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut git = NativeGit::new();
|
||||
let mut git = NativeGit::new(GitHubHost::default());
|
||||
git.environment = vec![
|
||||
("GIT_CONFIG_GLOBAL".into(), config.to_str().unwrap().into()),
|
||||
("GIT_CONFIG_NOSYSTEM".into(), "1".into()),
|
||||
|
|
@ -1081,7 +1083,7 @@ mod tests {
|
|||
let path = root.path().join("git");
|
||||
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut git = NativeGit::new();
|
||||
let mut git = NativeGit::new(GitHubHost::default());
|
||||
git.environment
|
||||
.push(("PATH".into(), root.path().to_str().unwrap().into()));
|
||||
(root, git)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use tokio::task;
|
|||
|
||||
use super::remote_workflow::{Interruption, NativeGit};
|
||||
use super::selection::{TargetSelection, WorkflowSelection};
|
||||
use crate::user_config;
|
||||
|
||||
/// Owns the canonical collector result without copying its contents. Local
|
||||
/// location metadata remains available for settings warnings and target
|
||||
|
|
@ -55,7 +56,7 @@ pub(super) async fn workflow(
|
|||
selector,
|
||||
revision,
|
||||
} => {
|
||||
let git = NativeGit::new();
|
||||
let git = NativeGit::new(user_config::github_host()?);
|
||||
let (repository, selector, revision) =
|
||||
(repository.clone(), selector.clone(), revision.clone());
|
||||
let closure = interruption
|
||||
|
|
@ -84,7 +85,7 @@ pub(super) async fn target(
|
|||
if !provider.clones_workspace() {
|
||||
bail!("Git targets require a clone-enabled environment");
|
||||
}
|
||||
let git = NativeGit::new();
|
||||
let git = NativeGit::new(user_config::github_host()?);
|
||||
let (repository, branch) = (repository.clone(), branch.clone());
|
||||
let target = interruption
|
||||
.owned(move |cancel| async move {
|
||||
|
|
@ -101,9 +102,11 @@ pub(super) async fn target(
|
|||
// The existing observer can push/query Git synchronously. Preserve its
|
||||
// behavior without blocking a Tokio worker or promising a new timeout.
|
||||
let provider = provider.clone();
|
||||
let host = user_config::github_host()?;
|
||||
let configured_repo_origin_url = configured_repo_origin_url.map(str::to_owned);
|
||||
let derived = task::spawn_blocking(move || {
|
||||
fabro_manifest::derive_run_target_for_provider(
|
||||
&host,
|
||||
&provider,
|
||||
&path,
|
||||
configured_repo_origin_url.as_deref(),
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu
|
|||
&bootstrap.storage_dir,
|
||||
&bootstrap.config_path,
|
||||
)
|
||||
.with_github_host(user_config::github_host()?)
|
||||
.with_finish_hook(persist_install_dev_token_hook());
|
||||
install::serve_install_command(bootstrap.bind_request, state, move |bind| {
|
||||
announce_install_mode(bind, &token, styles, printer);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use crate::args::ValidateArgs;
|
|||
use crate::command_context::CommandContext;
|
||||
use crate::commands::run::output::api_diagnostics_to_local;
|
||||
use crate::shared::{print_diagnostics, print_json_pretty, relative_path};
|
||||
use crate::user_config;
|
||||
|
||||
pub(crate) fn run(
|
||||
args: &ValidateArgs,
|
||||
|
|
@ -22,6 +23,7 @@ pub(crate) fn run(
|
|||
cwd: base_ctx.cwd().to_path_buf(),
|
||||
environment_defaults: fabro_environment::seeded_catalog_layer(),
|
||||
user_settings_path: Some(active_settings_path(None)),
|
||||
github_host: user_config::github_host()?,
|
||||
..Default::default()
|
||||
})?;
|
||||
let response = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use fabro_github::GitHubHost;
|
||||
use tokio::process::Command;
|
||||
use tracing::debug;
|
||||
|
||||
|
|
@ -6,19 +7,20 @@ use tracing::debug;
|
|||
/// All methods degrade gracefully: if `gh` is missing or not authenticated
|
||||
/// the caller receives `None` / empty results rather than errors.
|
||||
pub(crate) struct GhCli {
|
||||
_private: (),
|
||||
command: String,
|
||||
host: GitHubHost,
|
||||
}
|
||||
|
||||
impl GhCli {
|
||||
/// Attempt to find an authenticated `gh` CLI on PATH.
|
||||
///
|
||||
/// Returns `None` if `gh` is not installed or not authenticated
|
||||
/// against github.com.
|
||||
pub(crate) async fn detect() -> Option<Self> {
|
||||
Self::detect_with_command("gh").await
|
||||
/// against the configured GitHub host.
|
||||
pub(crate) async fn detect(host: &GitHubHost) -> Option<Self> {
|
||||
Self::detect_with_command("gh", host).await
|
||||
}
|
||||
|
||||
async fn detect_with_command(command: &str) -> Option<Self> {
|
||||
async fn detect_with_command(command: &str, host: &GitHubHost) -> Option<Self> {
|
||||
let version = Command::new(command).arg("--version").output().await;
|
||||
let Ok(output) = version else {
|
||||
debug!("gh CLI not found on PATH");
|
||||
|
|
@ -30,16 +32,19 @@ impl GhCli {
|
|||
}
|
||||
|
||||
let auth = Command::new(command)
|
||||
.args(["auth", "status", "--hostname", "github.com"])
|
||||
.args(["auth", "status", "--hostname", host.hostname()])
|
||||
.output()
|
||||
.await;
|
||||
match auth {
|
||||
Ok(o) if o.status.success() => {
|
||||
debug!("gh CLI available and authenticated for github.com");
|
||||
Some(Self { _private: () })
|
||||
debug!("gh CLI available and authenticated for {}", host.hostname());
|
||||
Some(Self {
|
||||
command: command.to_string(),
|
||||
host: host.clone(),
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
debug!("gh is not authenticated for github.com");
|
||||
debug!("gh is not authenticated for {}", host.hostname());
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -47,8 +52,15 @@ impl GhCli {
|
|||
|
||||
/// Return the login of the authenticated GitHub user, or `None` on failure.
|
||||
pub(crate) async fn authenticated_user(&self) -> Option<String> {
|
||||
let output = Command::new("gh")
|
||||
.args(["api", "--hostname", "github.com", "/user", "--jq", ".login"])
|
||||
let output = Command::new(&self.command)
|
||||
.args([
|
||||
"api",
|
||||
"--hostname",
|
||||
self.host.hostname(),
|
||||
"/user",
|
||||
"--jq",
|
||||
".login",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
|
|
@ -64,11 +76,11 @@ impl GhCli {
|
|||
///
|
||||
/// Returns an empty vec on any failure (network, parse, etc.).
|
||||
pub(crate) async fn list_admin_orgs(&self) -> Vec<String> {
|
||||
let output = Command::new("gh")
|
||||
let output = Command::new(&self.command)
|
||||
.args([
|
||||
"api",
|
||||
"--hostname",
|
||||
"github.com",
|
||||
self.host.hostname(),
|
||||
"--paginate",
|
||||
"/user/memberships/orgs",
|
||||
"--jq",
|
||||
|
|
@ -101,7 +113,51 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_none_when_gh_is_missing() {
|
||||
let result = GhCli::detect_with_command("fabro-test-gh-that-should-not-exist").await;
|
||||
let result = GhCli::detect_with_command(
|
||||
"fabro-test-gh-that-should-not-exist",
|
||||
&GitHubHost::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "unit test stages a fake gh script and reads its argument log with sync std::fs"
|
||||
)]
|
||||
async fn gh_invocations_target_the_configured_host() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = dir.path().join("args.log");
|
||||
let script = dir.path().join("gh");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\necho \"$@\" >> '{}'\n", log.display()),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
for (web_url, hostname) in [
|
||||
("https://github.com", "github.com"),
|
||||
("https://ghe.example.com:8443", "ghe.example.com"),
|
||||
] {
|
||||
let host = GitHubHost::new(web_url, None).unwrap();
|
||||
let gh = GhCli::detect_with_command(script.to_str().unwrap(), &host)
|
||||
.await
|
||||
.unwrap();
|
||||
gh.authenticated_user().await;
|
||||
gh.list_admin_orgs().await;
|
||||
|
||||
let logged = std::fs::read_to_string(&log).unwrap();
|
||||
std::fs::remove_file(&log).unwrap();
|
||||
let lines = logged.lines().collect::<Vec<_>>();
|
||||
assert_eq!(lines[0], "--version");
|
||||
assert_eq!(lines[1], format!("auth status --hostname {hostname}"));
|
||||
assert!(lines[2].starts_with(&format!("api --hostname {hostname} /user")));
|
||||
assert!(lines[3].starts_with(&format!("api --hostname {hostname} --paginate")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use fabro_static::EnvVars;
|
|||
use fabro_types::settings::RunNamespace;
|
||||
use fabro_types::settings::cli::CliTargetSettings;
|
||||
use fabro_types::settings::server::LogDestination;
|
||||
use fabro_types::{ServerSettings, UserSettings};
|
||||
use fabro_types::{GitHubHost, ServerSettings, UserSettings};
|
||||
use fabro_util::error::SharedError;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
use tokio::fs;
|
||||
|
|
@ -23,6 +23,14 @@ use tracing::debug;
|
|||
|
||||
use crate::args::ServerTargetArgs;
|
||||
|
||||
/// The GitHub deployment configured in the local server settings, with the
|
||||
/// `GITHUB_WEB_URL` / `GITHUB_BASE_URL` overrides applied.
|
||||
pub(crate) fn github_host() -> Result<GitHubHost> {
|
||||
let configured = ServerSettingsBuilder::load_default_github_host()
|
||||
.context("loading the GitHub host from the server settings")?;
|
||||
fabro_github::with_env_overrides(&configured)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct RunSettingsKeyPresence {
|
||||
pub(crate) run: bool,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
)]
|
||||
|
||||
use fabro_http::Url;
|
||||
use fabro_types::GitHubHost;
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -13,11 +14,12 @@ pub struct GithubEndpoints {
|
|||
}
|
||||
|
||||
impl GithubEndpoints {
|
||||
pub fn production_defaults() -> Self {
|
||||
pub fn from_host(host: &GitHubHost) -> Self {
|
||||
Self {
|
||||
oauth_base: Url::parse("https://github.com/").expect("github oauth base should parse"),
|
||||
api_base: Url::parse("https://api.github.com/")
|
||||
.expect("github api base should parse"),
|
||||
oauth_base: Url::parse(&format!("{}/", host.web_url()))
|
||||
.expect("GitHubHost web_url should parse as a URL"),
|
||||
api_base: Url::parse(&format!("{}/", host.api_url()))
|
||||
.expect("GitHubHost api_url should parse as a URL"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,16 +34,42 @@ impl GithubEndpoints {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::GitHubHost;
|
||||
|
||||
use super::GithubEndpoints;
|
||||
|
||||
#[test]
|
||||
fn production_defaults_match_current_github_urls() {
|
||||
let endpoints = GithubEndpoints::production_defaults();
|
||||
fn default_host_matches_current_github_urls() {
|
||||
let endpoints = GithubEndpoints::from_host(&GitHubHost::default());
|
||||
|
||||
assert_eq!(endpoints.oauth_base.as_str(), "https://github.com/");
|
||||
assert_eq!(endpoints.api_base.as_str(), "https://api.github.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_host_uses_its_web_origin_and_api_url() {
|
||||
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let endpoints = GithubEndpoints::from_host(&host);
|
||||
|
||||
assert_eq!(endpoints.oauth_base.as_str(), "https://ghe.example.com/");
|
||||
assert_eq!(
|
||||
endpoints.api_base.as_str(),
|
||||
"https://ghe.example.com/api/v3/"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints
|
||||
.oauth_base
|
||||
.join("login/oauth/authorize")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://ghe.example.com/login/oauth/authorize"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.api_base.join("user").unwrap().as_str(),
|
||||
"https://ghe.example.com/api/v3/user"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_bases_uses_custom_urls() {
|
||||
let endpoints = GithubEndpoints::with_bases(
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ pub(crate) struct ProductionAutomationRunMaterializer {
|
|||
remote_resolver: Arc<dyn AutomationGitRemoteResolver>,
|
||||
repo_cache: Arc<GitRepoCache>,
|
||||
version_store: WorkflowVersionStore,
|
||||
host: fabro_types::GitHubHost,
|
||||
}
|
||||
|
||||
/// Where to fetch a repository from and how to authenticate.
|
||||
|
|
@ -143,9 +144,9 @@ trait AutomationGitRemoteResolver: Send + Sync {
|
|||
}
|
||||
|
||||
struct ServerGitHubRemoteResolver {
|
||||
credentials: Option<fabro_github::GitHubCredentials>,
|
||||
api_base_url: String,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
credentials: Option<fabro_github::GitHubCredentials>,
|
||||
host: fabro_types::GitHubHost,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -154,12 +155,12 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver {
|
|||
let auth = git_checkout::resolve_git_read_auth_config(
|
||||
self.credentials.as_ref(),
|
||||
repo,
|
||||
&self.api_base_url,
|
||||
&self.host,
|
||||
self.http_client.clone(),
|
||||
)
|
||||
.await?;
|
||||
Ok(GitRemote {
|
||||
clone_url: git_checkout::github_clone_url(repo),
|
||||
clone_url: git_checkout::github_clone_url(repo, &self.host),
|
||||
auth,
|
||||
})
|
||||
}
|
||||
|
|
@ -168,7 +169,7 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver {
|
|||
impl ProductionAutomationRunMaterializer {
|
||||
pub(crate) fn new(
|
||||
github_credentials: Option<fabro_github::GitHubCredentials>,
|
||||
github_api_base_url: String,
|
||||
github_host: fabro_types::GitHubHost,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
repo_cache: Arc<GitRepoCache>,
|
||||
version_store: WorkflowVersionStore,
|
||||
|
|
@ -176,11 +177,12 @@ impl ProductionAutomationRunMaterializer {
|
|||
Self {
|
||||
remote_resolver: Arc::new(ServerGitHubRemoteResolver {
|
||||
credentials: github_credentials,
|
||||
api_base_url: github_api_base_url,
|
||||
host: github_host.clone(),
|
||||
http_client,
|
||||
}),
|
||||
repo_cache,
|
||||
version_store,
|
||||
host: github_host,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,13 +234,13 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
|
|||
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
|
||||
let validated_target = input
|
||||
.target
|
||||
.validate()
|
||||
.validate(&self.host)
|
||||
.map_err(|source| RunMaterializeError::InvalidTarget { source })?;
|
||||
let target_repo = validated_target.repository().clone();
|
||||
let mut exact_target = validated_target.into_target();
|
||||
let workflow_source = input
|
||||
.workflow_source
|
||||
.map(GitRunTarget::validate)
|
||||
.map(|source| source.validate(&self.host))
|
||||
.transpose()
|
||||
.map_err(|source| RunMaterializeError::InvalidWorkflowSource { source })?;
|
||||
// A workflow source naming the target's exact coordinate shares its
|
||||
|
|
@ -772,7 +774,7 @@ mod tests {
|
|||
) -> ProductionAutomationRunMaterializer {
|
||||
ProductionAutomationRunMaterializer::new(
|
||||
None,
|
||||
"https://api.github.com".to_string(),
|
||||
fabro_types::GitHubHost::default(),
|
||||
None,
|
||||
Arc::new(GitRepoCache::new(root.join("cache"))),
|
||||
store,
|
||||
|
|
|
|||
|
|
@ -4,24 +4,17 @@
|
|||
//! `index.html` so any inline `<script>` hashes don't drift from the
|
||||
//! template. Third-party sources are enumerated explicitly.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use fabro_types::GitHubHost;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
static POLICY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub(crate) const INSTALL_MODE_SCRIPT_BODY: &str = "window.__FABRO_MODE__ = \"install\";";
|
||||
|
||||
pub fn policy() -> &'static str {
|
||||
POLICY.get_or_init(build_policy)
|
||||
}
|
||||
|
||||
fn build_policy() -> String {
|
||||
pub fn policy(host: &GitHubHost) -> String {
|
||||
let mut script_hashes = inline_script_hashes_from_embedded_index();
|
||||
script_hashes.push(script_hash(INSTALL_MODE_SCRIPT_BODY));
|
||||
build_policy_with_hashes(&script_hashes)
|
||||
build_policy_with_hashes(&script_hashes, host)
|
||||
}
|
||||
|
||||
fn inline_script_hashes_from_embedded_index() -> Vec<String> {
|
||||
|
|
@ -70,7 +63,18 @@ fn script_hash(script: &str) -> String {
|
|||
format!("sha256-{}", STANDARD.encode(hash))
|
||||
}
|
||||
|
||||
fn build_policy_with_hashes(script_hashes: &[String]) -> String {
|
||||
/// Avatars of a GitHub Enterprise host are served from the host itself
|
||||
/// (`https://avatars.<host>`) or from its web origin, not from
|
||||
/// githubusercontent.com.
|
||||
fn github_img_sources(host: &GitHubHost) -> String {
|
||||
if host.is_github_com() {
|
||||
"https://avatars.githubusercontent.com".to_string()
|
||||
} else {
|
||||
format!("{} https://avatars.{}", host.web_url(), host.host())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_policy_with_hashes(script_hashes: &[String], host: &GitHubHost) -> String {
|
||||
let inline_script_sources = if script_hashes.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
|
|
@ -92,7 +96,7 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
|
|||
// terminal WebSocket working across browsers that do not treat `'self'`
|
||||
// as matching WebSocket schemes for connect-src. `frame-src https:`
|
||||
// allows signed sandbox VNC preview iframes from dynamic Daytona preview
|
||||
// hosts. `https://github.com` in form-action allows the install-mode
|
||||
// hosts. The GitHub web origin in form-action allows the install-mode
|
||||
// GitHub App manifest POST handoff. Loopback resume targets keep CLI
|
||||
// OAuth working when the browser-visible origin and canonical origin use
|
||||
// different loopback hostnames on a dynamically chosen port.
|
||||
|
|
@ -101,15 +105,17 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
|
|||
script-src 'self'{inline_script_sources} 'wasm-unsafe-eval'; \
|
||||
style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; \
|
||||
font-src 'self' https://fonts.gstatic.com; \
|
||||
img-src 'self' data: blob: https://avatars.githubusercontent.com; \
|
||||
img-src 'self' data: blob: {img_sources}; \
|
||||
connect-src 'self' ws: wss:; \
|
||||
worker-src 'self' blob:; \
|
||||
frame-src 'self' https:; \
|
||||
manifest-src 'self'; \
|
||||
frame-ancestors 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
|
||||
object-src 'none'"
|
||||
form-action 'self' {web_url} http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
|
||||
object-src 'none'",
|
||||
img_sources = github_img_sources(host),
|
||||
web_url = host.web_url(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +157,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn policy_allows_install_mode_inline_bootstrap() {
|
||||
let policy = build_policy();
|
||||
let policy = policy(&GitHubHost::default());
|
||||
let expected_hash = script_hash(INSTALL_MODE_SCRIPT_BODY);
|
||||
assert!(
|
||||
policy.contains(&format!("'{expected_hash}'")),
|
||||
|
|
@ -161,7 +167,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn policy_allows_cli_loopback_resume_form_posts() {
|
||||
let policy = build_policy_with_hashes(&[]);
|
||||
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
|
||||
assert!(
|
||||
policy.contains("form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume"),
|
||||
"CLI auth can submit the resume form to the canonical loopback origin on a dynamic port: {policy}"
|
||||
|
|
@ -170,7 +176,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn policy_is_constructed_with_expected_directives() {
|
||||
let policy = build_policy_with_hashes(&["sha256-abc".to_string()]);
|
||||
let policy = build_policy_with_hashes(&["sha256-abc".to_string()], &GitHubHost::default());
|
||||
assert!(policy.contains("default-src 'self'"));
|
||||
assert!(
|
||||
policy.contains("script-src 'self' 'sha256-abc' 'wasm-unsafe-eval'"),
|
||||
|
|
@ -191,9 +197,31 @@ mod tests {
|
|||
assert!(policy.contains("object-src 'none'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_com_policy_is_unchanged() {
|
||||
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
|
||||
assert!(policy.contains(
|
||||
"img-src 'self' data: blob: https://avatars.githubusercontent.com; connect-src"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_policy_uses_the_configured_web_origin() {
|
||||
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let policy = build_policy_with_hashes(&[], &host);
|
||||
assert!(policy.contains(
|
||||
"img-src 'self' data: blob: https://ghe.example.com https://avatars.ghe.example.com; connect-src"
|
||||
));
|
||||
assert!(policy.contains(
|
||||
"form-action 'self' https://ghe.example.com http://127.0.0.1:*/auth/cli/resume"
|
||||
));
|
||||
assert!(!policy.contains("https://github.com"));
|
||||
assert!(!policy.contains("githubusercontent.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_is_valid_without_inline_scripts() {
|
||||
let policy = build_policy_with_hashes(&[]);
|
||||
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
|
||||
assert!(
|
||||
policy.contains("script-src 'self' 'wasm-unsafe-eval'"),
|
||||
"empty hash list should not produce a stray space: {policy}"
|
||||
|
|
@ -204,7 +232,7 @@ mod tests {
|
|||
fn embedded_spa_index_builds_a_policy() {
|
||||
// Guards against the embedded asset going missing or becoming
|
||||
// unreadable in a way that would leave the CSP header blank.
|
||||
let policy = build_policy();
|
||||
let policy = policy(&GitHubHost::default());
|
||||
assert!(
|
||||
policy.contains("script-src 'self'"),
|
||||
"embedded SPA policy should contain a script-src directive"
|
||||
|
|
|
|||
|
|
@ -1189,6 +1189,7 @@ mod runs {
|
|||
},
|
||||
automation: None,
|
||||
repository: Some(RepositoryRef::from_origin_and_source(
|
||||
&fabro_types::GitHubHost::default(),
|
||||
repo_origin_url,
|
||||
source_directory.as_deref(),
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
|
|||
};
|
||||
let probe = timeout(
|
||||
EXTERNAL_SERVICE_PROBE_TIMEOUT,
|
||||
http.get(format!("{}/user", fabro_github::github_api_base_url()))
|
||||
http.get(format!("{}/user", state.github_host.api_url()))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "fabro-server")
|
||||
|
|
@ -549,7 +549,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
|
|||
};
|
||||
let auth_result = timeout(
|
||||
EXTERNAL_SERVICE_PROBE_TIMEOUT,
|
||||
fabro_github::get_authenticated_app(&http, &jwt, &fabro_github::github_api_base_url()),
|
||||
fabro_github::get_authenticated_app(&http, &jwt, state.github_host.api_url()),
|
||||
)
|
||||
.await;
|
||||
match auth_result {
|
||||
|
|
|
|||
|
|
@ -264,8 +264,11 @@ async fn bare_clone_may_be_corrupt(bare_dir: &Path) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
|
||||
let mut url = repo.https_url();
|
||||
pub(crate) fn github_clone_url(
|
||||
repo: &GitHubRepositorySlug,
|
||||
host: &fabro_types::GitHubHost,
|
||||
) -> String {
|
||||
let mut url = repo.https_url(host);
|
||||
url.push_str(".git");
|
||||
url
|
||||
}
|
||||
|
|
@ -313,17 +316,15 @@ impl GitAuthConfig {
|
|||
pub(crate) async fn resolve_git_read_auth_config(
|
||||
credentials: Option<&fabro_github::GitHubCredentials>,
|
||||
repo: &GitHubRepositorySlug,
|
||||
github_api_base_url: &str,
|
||||
host: &fabro_types::GitHubHost,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
) -> anyhow::Result<Option<GitAuthConfig>> {
|
||||
let Some(credentials) = credentials else {
|
||||
return Ok(None);
|
||||
};
|
||||
let context = match http_client {
|
||||
Some(client) => {
|
||||
fabro_github::GitHubContext::with_http_client(credentials, github_api_base_url, client)
|
||||
}
|
||||
None => fabro_github::GitHubContext::new(credentials, github_api_base_url),
|
||||
Some(client) => fabro_github::GitHubContext::with_http_client(credentials, host, client),
|
||||
None => fabro_github::GitHubContext::new(credentials, host),
|
||||
};
|
||||
let credentials =
|
||||
fabro_github::resolve_read_only_clone_credentials(&context, repo.owner(), repo.repo())
|
||||
|
|
@ -742,11 +743,14 @@ mod tests {
|
|||
assert_eq!(repo.owner(), "fabro-sh");
|
||||
assert_eq!(repo.repo(), "fabro");
|
||||
assert_eq!(
|
||||
github_clone_url(&repo),
|
||||
github_clone_url(&repo, &fabro_types::GitHubHost::default()),
|
||||
"https://github.com/fabro-sh/fabro.git"
|
||||
);
|
||||
assert_eq!(repo.https_url(), "https://github.com/fabro-sh/fabro");
|
||||
assert!(!github_clone_url(&repo).contains('@'));
|
||||
assert_eq!(
|
||||
repo.https_url(&fabro_types::GitHubHost::default()),
|
||||
"https://github.com/fabro-sh/fabro"
|
||||
);
|
||||
assert!(!github_clone_url(&repo, &fabro_types::GitHubHost::default()).contains('@'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -755,13 +759,16 @@ mod tests {
|
|||
|
||||
assert_eq!(repo.owner(), "owner");
|
||||
assert_eq!(repo.repo(), ".github");
|
||||
assert_eq!(repo.https_url(), "https://github.com/owner/.github");
|
||||
assert_eq!(
|
||||
repo.https_url(&fabro_types::GitHubHost::default()),
|
||||
"https://github.com/owner/.github"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_cache_command_plans_use_argv_prompt_disable_and_timeouts() {
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
let clone_url = github_clone_url(&repo);
|
||||
let clone_url = github_clone_url(&repo, &fabro_types::GitHubHost::default());
|
||||
let temp = TempDir::new().unwrap();
|
||||
let bare_dir = temp.path().join("fabro-sh/fabro.git");
|
||||
let worktree_dir = temp.path().join("worktree/repo");
|
||||
|
|
@ -849,7 +856,7 @@ mod tests {
|
|||
#[test]
|
||||
fn credential_config_env_keeps_clone_url_uncredentialed() {
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
let clone_url = github_clone_url(&repo);
|
||||
let clone_url = github_clone_url(&repo, &fabro_types::GitHubHost::default());
|
||||
let auth = GitAuthConfig::from_parts("x-access-token", "ghu_secret");
|
||||
let plan = build_bare_clone_plan(&clone_url, Path::new("/tmp/fabro-checkout"), Some(&auth));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use fabro_static::EnvVars;
|
||||
use fabro_types::GitHubHost;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use tokio::process::Command;
|
||||
|
|
@ -56,6 +57,7 @@ pub struct TailscaleFunnelManager {
|
|||
impl TailscaleFunnelManager {
|
||||
pub async fn start(
|
||||
main_server_port: u16,
|
||||
host: &GitHubHost,
|
||||
app_id: &str,
|
||||
private_key_pem: &str,
|
||||
) -> anyhow::Result<Self> {
|
||||
|
|
@ -63,7 +65,9 @@ impl TailscaleFunnelManager {
|
|||
info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled");
|
||||
|
||||
let webhook_url = format!("{funnel_url}{WEBHOOK_ROUTE}");
|
||||
match fabro_github::update_app_webhook_config(app_id, private_key_pem, &webhook_url).await {
|
||||
match fabro_github::update_app_webhook_config(host, app_id, private_key_pem, &webhook_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!(url = %webhook_url, "GitHub App webhook URL updated");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use fabro_static::EnvVars;
|
|||
use fabro_store::ArtifactStore;
|
||||
use fabro_types::settings::server::ObjectStoreSettings;
|
||||
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
|
||||
use fabro_types::{SandboxProviderKind, ServerSettings};
|
||||
use fabro_types::{GitHubHost, SandboxProviderKind, ServerSettings};
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
use fabro_util::{Home, session_secret};
|
||||
use fabro_vault::SecretType as VaultSecretType;
|
||||
|
|
@ -52,7 +52,7 @@ use crate::sandbox_access::{
|
|||
};
|
||||
use crate::serve::{self, DEFAULT_TCP_PORT};
|
||||
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
|
||||
use crate::{security_headers, server, static_files};
|
||||
use crate::{csp, security_headers, server, static_files};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InstallAppState {
|
||||
|
|
@ -65,6 +65,7 @@ pub struct InstallAppState {
|
|||
first_operator: Arc<Mutex<Option<InstallOperatorFingerprint>>>,
|
||||
finish_in_progress: Arc<AtomicBool>,
|
||||
upstreams: InstallUpstreamConfig,
|
||||
github_host: GitHubHost,
|
||||
static_asset_root: Option<Arc<Path>>,
|
||||
on_finish: Option<Arc<dyn Fn() + Send + Sync>>,
|
||||
finish_hook: Option<InstallFinishHook>,
|
||||
|
|
@ -91,7 +92,6 @@ struct InstallOperatorFingerprint {
|
|||
remote_ip: Option<String>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_INSTALL_GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
||||
const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276";
|
||||
const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
|
||||
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
|
@ -115,6 +115,7 @@ impl InstallAppState {
|
|||
first_operator: Arc::new(Mutex::new(None)),
|
||||
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
upstreams: InstallUpstreamConfig::default(),
|
||||
github_host: GitHubHost::default(),
|
||||
static_asset_root: None,
|
||||
on_finish: None,
|
||||
finish_hook: None,
|
||||
|
|
@ -157,6 +158,7 @@ impl InstallAppState {
|
|||
first_operator: Arc::new(Mutex::new(None)),
|
||||
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
upstreams: InstallUpstreamConfig::default(),
|
||||
github_host: GitHubHost::default(),
|
||||
static_asset_root: None,
|
||||
on_finish: None,
|
||||
finish_hook: None,
|
||||
|
|
@ -203,6 +205,12 @@ impl InstallAppState {
|
|||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_github_host(mut self, host: GitHubHost) -> Self {
|
||||
self.github_host = host;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_github_api_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.upstreams.github_api_base_url = Some(base_url.into());
|
||||
|
|
@ -566,11 +574,12 @@ enum GitHubAppOwner {
|
|||
}
|
||||
|
||||
impl GitHubAppOwner {
|
||||
fn manifest_form_action(&self) -> String {
|
||||
fn manifest_form_action(&self, host: &GitHubHost) -> String {
|
||||
let web_url = host.web_url();
|
||||
match self {
|
||||
Self::Personal => "https://github.com/settings/apps/new".to_string(),
|
||||
Self::Personal => format!("{web_url}/settings/apps/new"),
|
||||
Self::Organization(org) => {
|
||||
format!("https://github.com/organizations/{org}/settings/apps/new")
|
||||
format!("{web_url}/organizations/{org}/settings/apps/new")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -602,6 +611,7 @@ impl TryFrom<GithubAppOwnerInput> for GitHubAppOwner {
|
|||
}
|
||||
|
||||
pub fn build_install_router(state: InstallAppState) -> Router {
|
||||
let csp_policy = Arc::new(csp::policy(&state.github_host));
|
||||
let static_asset_root = state.static_asset_root.clone();
|
||||
|
||||
Router::new()
|
||||
|
|
@ -670,7 +680,10 @@ pub fn build_install_router(state: InstallAppState) -> Router {
|
|||
// router; a first-run setup over a slow link needs compression just
|
||||
// as much.
|
||||
.layer(server::compression_layer())
|
||||
.layer(middleware::from_fn(security_headers::layer))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
csp_policy,
|
||||
security_headers::layer,
|
||||
))
|
||||
}
|
||||
|
||||
struct InstallFinishGuard {
|
||||
|
|
@ -783,6 +796,7 @@ async fn get_install_session(
|
|||
"prefill": {
|
||||
"canonical_url": detect_canonical_url(&headers),
|
||||
"object_store_local_root": default_local_object_store_root(&state),
|
||||
"github_web_url": state.github_host.web_url(),
|
||||
}
|
||||
}))
|
||||
.into_response()
|
||||
|
|
@ -1422,7 +1436,7 @@ async fn post_install_github_app_manifest(
|
|||
|
||||
Json(serde_json::json!({
|
||||
"manifest": manifest,
|
||||
"github_form_action": owner.manifest_form_action(),
|
||||
"github_form_action": owner.manifest_form_action(&state.github_host),
|
||||
"state": state_token,
|
||||
}))
|
||||
.into_response()
|
||||
|
|
@ -2178,7 +2192,7 @@ async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::
|
|||
.upstreams
|
||||
.github_api_base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
|
||||
.unwrap_or_else(|| state.github_host.api_url().to_string());
|
||||
let endpoint = install_upstream_endpoint(&base_url, &["user"])?;
|
||||
let client = install_http_client_for_url(&base_url)?;
|
||||
let response = client
|
||||
|
|
@ -2210,7 +2224,7 @@ async fn exchange_github_app_manifest_code(
|
|||
.upstreams
|
||||
.github_api_base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
|
||||
.unwrap_or_else(|| state.github_host.api_url().to_string());
|
||||
let endpoint = install_upstream_endpoint(&base_url, &["app-manifests", code, "conversions"])?;
|
||||
let client = install_http_client_for_url(&base_url)?;
|
||||
let response = client
|
||||
|
|
@ -2319,15 +2333,15 @@ mod tests {
|
|||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
DEFAULT_INSTALL_GITHUB_API_BASE_URL, GitHubAppOwner, GithubAppInstall, GithubInstallState,
|
||||
InstallAppState, InstallAwsCredentialPair, InstallFinishGuard,
|
||||
InstallObjectStoreCredentialMode, InstallObjectStoreInput, InstallObjectStoreProvider,
|
||||
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
|
||||
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
|
||||
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
|
||||
install_catalog_provider, install_object_store_lookup, lock_unpoisoned,
|
||||
post_install_finish, provider_base_url_override, resolve_install_object_store_state,
|
||||
token_is_valid, write_artifact_store_metadata,
|
||||
GitHubAppOwner, GitHubHost, GithubAppInstall, GithubInstallState, InstallAppState,
|
||||
InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode,
|
||||
InstallObjectStoreInput, InstallObjectStoreProvider, InstallObjectStoreState,
|
||||
InstallSandboxProviderState, InstallSandboxState, InstallTokenQuery, LlmProvidersInput,
|
||||
PendingInstall, ServerConfigInput, ServerSecrets, build_github_app_manifest,
|
||||
classify_object_store_validation_error, detect_canonical_url, install_catalog_provider,
|
||||
install_object_store_lookup, lock_unpoisoned, post_install_finish,
|
||||
provider_base_url_override, resolve_install_object_store_state, token_is_valid,
|
||||
write_artifact_store_metadata,
|
||||
};
|
||||
|
||||
#[test]
|
||||
|
|
@ -2529,10 +2543,38 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn install_github_requests_default_to_fixed_github_api_base_url() {
|
||||
fn install_github_requests_default_to_the_host_api_url() {
|
||||
let state = InstallAppState::for_test("expected");
|
||||
assert_eq!(state.github_host.api_url(), "https://api.github.com");
|
||||
let state =
|
||||
state.with_github_host(GitHubHost::new("https://ghe.example.com", None).unwrap());
|
||||
assert_eq!(
|
||||
DEFAULT_INSTALL_GITHUB_API_BASE_URL,
|
||||
"https://api.github.com"
|
||||
state.github_host.api_url(),
|
||||
"https://ghe.example.com/api/v3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_form_action_targets_the_host_web_origin() {
|
||||
let github_com = GitHubHost::default();
|
||||
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let org = GitHubAppOwner::Organization("acme".to_string());
|
||||
|
||||
assert_eq!(
|
||||
GitHubAppOwner::Personal.manifest_form_action(&github_com),
|
||||
"https://github.com/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
org.manifest_form_action(&github_com),
|
||||
"https://github.com/organizations/acme/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
GitHubAppOwner::Personal.manifest_form_action(&ghes),
|
||||
"https://ghe.example.com/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
org.manifest_form_action(&ghes),
|
||||
"https://ghe.example.com/organizations/acme/settings/apps/new"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ async fn build_preflight_report(
|
|||
check: &ManifestCheck,
|
||||
shape: &WorkflowShape,
|
||||
) -> Result<(CheckReport, bool)> {
|
||||
let mut checks = base_preflight_checks(prepared, shape);
|
||||
let mut checks = base_preflight_checks(prepared, shape, &state.github_host);
|
||||
if check.has_errors() {
|
||||
return Ok((
|
||||
CheckReport {
|
||||
|
|
@ -513,10 +513,17 @@ async fn build_preflight_report(
|
|||
prepared,
|
||||
&resolved_run,
|
||||
github_app.clone(),
|
||||
&state.github_host,
|
||||
)
|
||||
.await;
|
||||
let github_token_ok = run_github_token_check(
|
||||
&mut checks,
|
||||
prepared,
|
||||
&resolved_run,
|
||||
github_app,
|
||||
&state.github_host,
|
||||
)
|
||||
.await;
|
||||
let github_token_ok =
|
||||
run_github_token_check(&mut checks, prepared, &resolved_run, github_app).await;
|
||||
|
||||
let checks_ok = sandbox_ok && repository_access_ok && github_token_ok;
|
||||
|
||||
|
|
@ -532,12 +539,16 @@ async fn build_preflight_report(
|
|||
))
|
||||
}
|
||||
|
||||
fn base_preflight_checks(prepared: &PreparedManifest, shape: &WorkflowShape) -> Vec<CheckResult> {
|
||||
fn base_preflight_checks(
|
||||
prepared: &PreparedManifest,
|
||||
shape: &WorkflowShape,
|
||||
host: &fabro_types::GitHubHost,
|
||||
) -> Vec<CheckResult> {
|
||||
let repo_summary = prepared.git.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|git| {
|
||||
let https = fabro_github::ssh_url_to_https(&git.origin_url);
|
||||
fabro_github::parse_github_owner_repo(&https).map_or_else(
|
||||
fabro_github::parse_github_owner_repo(host, &https).map_or_else(
|
||||
|_| git.origin_url.clone(),
|
||||
|(owner, repo)| format!("{owner}/{repo}"),
|
||||
)
|
||||
|
|
@ -699,6 +710,7 @@ async fn run_repository_access_check(
|
|||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
host: &fabro_types::GitHubHost,
|
||||
) -> bool {
|
||||
run_repository_access_check_with(
|
||||
checks,
|
||||
|
|
@ -706,7 +718,8 @@ async fn run_repository_access_check(
|
|||
prepared,
|
||||
resolved_run,
|
||||
github_app,
|
||||
check_git_remote_ref,
|
||||
host,
|
||||
|request, github_app| check_git_remote_ref(request, github_app, host),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -717,6 +730,7 @@ async fn run_repository_access_check_with<F, Fut>(
|
|||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
host: &fabro_types::GitHubHost,
|
||||
check_remote_ref: F,
|
||||
) -> bool
|
||||
where
|
||||
|
|
@ -734,7 +748,7 @@ where
|
|||
};
|
||||
|
||||
let origin_url = fabro_github::normalize_repo_origin_url(&git.origin_url);
|
||||
if let Err(err) = fabro_github::parse_github_owner_repo(&origin_url) {
|
||||
if let Err(err) = fabro_github::parse_github_owner_repo(host, &origin_url) {
|
||||
checks.push(CheckResult {
|
||||
name: "Repository Access".into(),
|
||||
status: CheckStatus::Error,
|
||||
|
|
@ -780,11 +794,12 @@ where
|
|||
async fn check_git_remote_ref(
|
||||
request: GitRemoteRefCheck,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
host: &fabro_types::GitHubHost,
|
||||
) -> Result<(), String> {
|
||||
let auth_url = match github_app.as_ref() {
|
||||
Some(creds) => Some(
|
||||
fabro_github::resolve_authenticated_url(
|
||||
&fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()),
|
||||
&fabro_github::GitHubContext::new(creds, host),
|
||||
&request.origin_url,
|
||||
)
|
||||
.await
|
||||
|
|
@ -940,14 +955,16 @@ async fn run_github_token_check(
|
|||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
host: &fabro_types::GitHubHost,
|
||||
) -> bool {
|
||||
run_github_token_check_with(
|
||||
checks,
|
||||
prepared,
|
||||
resolved_run,
|
||||
github_app,
|
||||
mint_scoped_github_token,
|
||||
probe_github_repository,
|
||||
host,
|
||||
|access, creds| mint_scoped_github_token(access, creds, host),
|
||||
|slug, token| probe_github_repository(slug, token, host),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -957,6 +974,7 @@ async fn run_github_token_check_with<M, MFut, P, PFut>(
|
|||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
host: &fabro_types::GitHubHost,
|
||||
mint_scoped_token: M,
|
||||
probe_repository: P,
|
||||
) -> bool
|
||||
|
|
@ -998,6 +1016,7 @@ where
|
|||
checks,
|
||||
prepared,
|
||||
github_app,
|
||||
host,
|
||||
&integration.permissions,
|
||||
perm_details,
|
||||
)
|
||||
|
|
@ -1050,6 +1069,7 @@ where
|
|||
// The same validated access value runtime initialization constructs, so
|
||||
// preflight and runtime cannot disagree about the effective set.
|
||||
let access = match fabro_github::GitHubRepositoryAccess::new(
|
||||
host,
|
||||
Some(origin_url),
|
||||
&integration.additional_repositories,
|
||||
integration.permissions.clone(),
|
||||
|
|
@ -1163,11 +1183,12 @@ async fn check_primary_only_github_token(
|
|||
checks: &mut Vec<CheckResult>,
|
||||
prepared: &PreparedManifest,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
host: &fabro_types::GitHubHost,
|
||||
permissions: &HashMap<String, String>,
|
||||
perm_details: Vec<CheckDetail>,
|
||||
) -> bool {
|
||||
if let (Some(creds), Some(git)) = (&github_app, prepared.git.as_ref()) {
|
||||
match mint_github_token(creds, &git.origin_url, permissions).await {
|
||||
match mint_github_token(creds, host, &git.origin_url, permissions).await {
|
||||
Ok(_) => {
|
||||
checks.push(CheckResult {
|
||||
name: "GitHub Token".into(),
|
||||
|
|
@ -1207,9 +1228,10 @@ async fn check_primary_only_github_token(
|
|||
async fn mint_scoped_github_token(
|
||||
access: fabro_github::GitHubRepositoryAccess,
|
||||
creds: fabro_github::GitHubCredentials,
|
||||
host: &fabro_types::GitHubHost,
|
||||
) -> std::result::Result<ResolvedToken, String> {
|
||||
let source =
|
||||
InstallationTokenSource::for_access(&creds, &access).map_err(|err| format!("{err:#}"))?;
|
||||
let source = InstallationTokenSource::for_access(&creds, host, &access)
|
||||
.map_err(|err| format!("{err:#}"))?;
|
||||
source.resolve().await.map_err(|err| format!("{err:#}"))
|
||||
}
|
||||
|
||||
|
|
@ -1222,9 +1244,10 @@ async fn mint_scoped_github_token(
|
|||
async fn probe_github_repository(
|
||||
slug: fabro_types::GitHubRepositorySlug,
|
||||
token: ResolvedToken,
|
||||
host: &fabro_types::GitHubHost,
|
||||
) -> std::result::Result<(), String> {
|
||||
let url = slug.https_url();
|
||||
probe_with_replication_retry(token.snapshot, || run_probe_ls_remote(&url, &token)).await
|
||||
let url = slug.https_url(host);
|
||||
probe_with_replication_retry(token.snapshot, || run_probe_ls_remote(host, &url, &token)).await
|
||||
}
|
||||
|
||||
/// Retry auth-shaped failures with the SAME token: replication of a given
|
||||
|
|
@ -1347,30 +1370,29 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
async fn run_probe_ls_remote(url: &str, token: &ResolvedToken) -> std::result::Result<(), String> {
|
||||
async fn run_probe_ls_remote(
|
||||
host: &fabro_types::GitHubHost,
|
||||
url: &str,
|
||||
token: &ResolvedToken,
|
||||
) -> std::result::Result<(), String> {
|
||||
let mut command = Command::new("git");
|
||||
fabro_github::apply_probe_git_env(&mut command, token.token.expose());
|
||||
fabro_github::apply_probe_git_env(&mut command, host, token.token.expose());
|
||||
command.args(["ls-remote", url, "HEAD"]);
|
||||
run_ls_remote(command).await
|
||||
}
|
||||
|
||||
async fn mint_github_token(
|
||||
creds: &fabro_github::GitHubCredentials,
|
||||
host: &fabro_types::GitHubHost,
|
||||
origin_url: &str,
|
||||
permissions: &HashMap<String, String>,
|
||||
) -> Result<String> {
|
||||
let https_url = fabro_github::ssh_url_to_https(origin_url);
|
||||
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)?;
|
||||
let (owner, repo) = fabro_github::parse_github_owner_repo(host, &https_url)?;
|
||||
let client = fabro_http::http_client()?;
|
||||
let perms_json = serde_json::to_value(permissions)?;
|
||||
creds
|
||||
.resolve_bearer_token(
|
||||
&client,
|
||||
&owner,
|
||||
&repo,
|
||||
&fabro_github::github_api_base_url(),
|
||||
perms_json,
|
||||
)
|
||||
.resolve_bearer_token(&client, &owner, &repo, host, perms_json)
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
@ -1889,6 +1911,7 @@ provider = "local"
|
|||
&prepared,
|
||||
&resolved,
|
||||
None,
|
||||
&fabro_types::GitHubHost::default(),
|
||||
move |request, _github_app| {
|
||||
calls_for_check.lock().unwrap().push(request);
|
||||
async { Ok(()) }
|
||||
|
|
@ -1918,6 +1941,7 @@ provider = "local"
|
|||
&prepared,
|
||||
&resolved,
|
||||
None,
|
||||
&fabro_types::GitHubHost::default(),
|
||||
move |request, _github_app| {
|
||||
calls_for_check.lock().unwrap().push(request);
|
||||
async { Ok(()) }
|
||||
|
|
@ -1959,6 +1983,7 @@ provider = "local"
|
|||
&prepared,
|
||||
&resolved,
|
||||
None,
|
||||
&fabro_types::GitHubHost::default(),
|
||||
move |request, _github_app| {
|
||||
calls_for_check.lock().unwrap().push(request);
|
||||
async { Ok(()) }
|
||||
|
|
@ -1991,6 +2016,7 @@ provider = "local"
|
|||
&prepared,
|
||||
&resolved,
|
||||
None,
|
||||
&fabro_types::GitHubHost::default(),
|
||||
|_request, _github_app| async { Err("remote branch not found".to_string()) },
|
||||
)
|
||||
.await;
|
||||
|
|
@ -3155,6 +3181,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
&prepared,
|
||||
&resolved,
|
||||
Some(pat_creds()),
|
||||
&fabro_types::GitHubHost::default(),
|
||||
move |access, _creds| {
|
||||
minted_for_seam.lock().unwrap().push(access);
|
||||
async { Ok(static_token("scoped-token")) }
|
||||
|
|
@ -3212,6 +3239,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
&prepared,
|
||||
&resolved,
|
||||
Some(pat_creds()),
|
||||
&fabro_types::GitHubHost::default(),
|
||||
|_access, _creds| async {
|
||||
Err(
|
||||
"the GitHub App installation cannot see repository acme/keystone; add \
|
||||
|
|
@ -3250,6 +3278,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
&prepared,
|
||||
&resolved,
|
||||
Some(pat_creds()),
|
||||
&fabro_types::GitHubHost::default(),
|
||||
|_access, _creds| async { Ok(static_token("scoped-token")) },
|
||||
|slug, _token| async move {
|
||||
if slug.repo() == "keystone" {
|
||||
|
|
@ -3296,6 +3325,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
&prepared,
|
||||
&resolved,
|
||||
Some(pat_creds()),
|
||||
&fabro_types::GitHubHost::default(),
|
||||
|_access, _creds| async { Ok(static_token("scoped-token")) },
|
||||
|_slug, _token| async { Ok(()) },
|
||||
)
|
||||
|
|
@ -3324,6 +3354,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
&prepared,
|
||||
&resolved,
|
||||
Some(pat_creds()),
|
||||
&fabro_types::GitHubHost::default(),
|
||||
|_access, _creds| async { Ok(static_token("scoped-token")) },
|
||||
|_slug, _token| async { Ok(()) },
|
||||
)
|
||||
|
|
@ -3354,6 +3385,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
&prepared,
|
||||
&resolved,
|
||||
None,
|
||||
&fabro_types::GitHubHost::default(),
|
||||
|_access, _creds| async { Ok(static_token("scoped-token")) },
|
||||
|_slug, _token| async { Ok(()) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,26 +4,26 @@
|
|||
//! can still override any header by setting their own value first — the
|
||||
//! defaults here only fill in what's missing.
|
||||
|
||||
use axum::extract::Request;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
use crate::csp;
|
||||
|
||||
pub async fn layer(req: Request, next: Next) -> Response {
|
||||
pub async fn layer(State(csp_policy): State<Arc<String>>, req: Request, next: Next) -> Response {
|
||||
let is_https = request_is_https(&req);
|
||||
let mut response = next.run(req).await;
|
||||
apply_defaults(response.headers_mut(), is_https);
|
||||
apply_csp(response.headers_mut());
|
||||
apply_csp(response.headers_mut(), &csp_policy);
|
||||
response
|
||||
}
|
||||
|
||||
fn apply_csp(headers: &mut HeaderMap) {
|
||||
fn apply_csp(headers: &mut HeaderMap, policy: &str) {
|
||||
// Report-only while we tune the policy: browsers evaluate violations and log
|
||||
// them to the console without blocking any resources. Switch back to the
|
||||
// enforcing `content-security-policy` header once the policy is clean.
|
||||
if let Ok(value) = HeaderValue::from_str(csp::policy()) {
|
||||
if let Ok(value) = HeaderValue::from_str(policy) {
|
||||
headers
|
||||
.entry(HeaderName::from_static(
|
||||
"content-security-policy-report-only",
|
||||
|
|
@ -202,7 +202,7 @@ mod tests {
|
|||
#[test]
|
||||
fn csp_is_report_only_not_enforced() {
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_csp(&mut headers);
|
||||
apply_csp(&mut headers, "default-src 'self'");
|
||||
assert!(headers.contains_key("content-security-policy-report-only"));
|
||||
assert!(!headers.contains_key("content-security-policy"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,7 +334,9 @@ async fn start_webhook_strategy(
|
|||
);
|
||||
return Ok(None);
|
||||
};
|
||||
match TailscaleFunnelManager::start(port, &app_id, &private_key_pem).await {
|
||||
match TailscaleFunnelManager::start(port, &state.github_host, &app_id, &private_key_pem)
|
||||
.await
|
||||
{
|
||||
Ok(manager) => Ok(Some(manager)),
|
||||
Err(err) => {
|
||||
error!(
|
||||
|
|
@ -356,8 +358,13 @@ async fn start_webhook_strategy(
|
|||
)
|
||||
})?;
|
||||
let webhook_url = format!("{}{WEBHOOK_ROUTE}", server_api_url.trim_end_matches('/'));
|
||||
match fabro_github::update_app_webhook_config(&app_id, &private_key_pem, &webhook_url)
|
||||
.await
|
||||
match fabro_github::update_app_webhook_config(
|
||||
&state.github_host,
|
||||
&app_id,
|
||||
&private_key_pem,
|
||||
&webhook_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => info!(url = %webhook_url, "GitHub App webhook URL updated"),
|
||||
Err(err) => warn!(
|
||||
|
|
@ -737,7 +744,7 @@ where
|
|||
preloaded_vault: startup_vault.into_vault(),
|
||||
server_secrets,
|
||||
env_lookup,
|
||||
github_api_base_url: None,
|
||||
github_host: None,
|
||||
active_config_path,
|
||||
http_client: None,
|
||||
sandbox_inventory: None,
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ use crate::worker_control::{
|
|||
use crate::worker_runtime::{LocalWorkerRuntime, WorkerLaunchSpec, WorkerRef, WorkerRuntime};
|
||||
use crate::worker_token::{WorkerScopeSet, WorkerTokenKeys, issue_worker_token_with_scopes};
|
||||
use crate::{
|
||||
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
|
||||
canonical_host, csp, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
|
||||
};
|
||||
|
||||
mod automation_scheduler;
|
||||
|
|
@ -1152,28 +1152,28 @@ pub struct AppState {
|
|||
control_request_locks: KeyedMutex<RunId>,
|
||||
parent_link_lock: AsyncMutex<()>,
|
||||
|
||||
pub(super) server_secrets: ServerSecrets,
|
||||
pub(crate) llm_source: Arc<dyn CredentialProvider>,
|
||||
pub(super) server_secrets: ServerSecrets,
|
||||
pub(crate) llm_source: Arc<dyn CredentialProvider>,
|
||||
/// The database pool the stores share, for the Petri run store a
|
||||
/// server-process run writes its records through.
|
||||
pub(crate) db_pool: DbPool,
|
||||
manifest_run_defaults: RwLock<Arc<RunLayer>>,
|
||||
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
|
||||
pub(crate) db_pool: DbPool,
|
||||
manifest_run_defaults: RwLock<Arc<RunLayer>>,
|
||||
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
|
||||
pub(crate) server_settings: RwLock<Arc<ServerSettings>>,
|
||||
effective_web_url: RwLock<String>,
|
||||
catalog: RwLock<Arc<Catalog>>,
|
||||
pub(crate) env_lookup: EnvLookup,
|
||||
pub(crate) github_api_base_url: String,
|
||||
active_config_path: PathBuf,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
sandbox_inventory: SandboxInventory,
|
||||
shutdown: CancellationToken,
|
||||
shutting_down: AtomicBool,
|
||||
effective_web_url: RwLock<String>,
|
||||
catalog: RwLock<Arc<Catalog>>,
|
||||
pub(crate) env_lookup: EnvLookup,
|
||||
pub(crate) github_host: fabro_types::GitHubHost,
|
||||
active_config_path: PathBuf,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
sandbox_inventory: SandboxInventory,
|
||||
shutdown: CancellationToken,
|
||||
shutting_down: AtomicBool,
|
||||
/// Test switch: execute runs in this process instead of a worker.
|
||||
execute_in_process: bool,
|
||||
slack_service: Option<Arc<SlackService>>,
|
||||
slack_started: AtomicBool,
|
||||
github_webhook_secret: Option<String>,
|
||||
execute_in_process: bool,
|
||||
slack_service: Option<Arc<SlackService>>,
|
||||
slack_started: AtomicBool,
|
||||
github_webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct AppStores {
|
||||
|
|
@ -1277,7 +1277,7 @@ impl AppState {
|
|||
.map_err(|source| RunMaterializeError::LoadCredentials { source })?;
|
||||
ProductionAutomationRunMaterializer::new(
|
||||
credentials,
|
||||
self.github_api_base_url.clone(),
|
||||
self.github_host.clone(),
|
||||
self.http_client.clone(),
|
||||
Arc::clone(&self.automation_repo_cache),
|
||||
fabro_workflow_version::WorkflowVersionStore::new(self.store_ref().blobs()),
|
||||
|
|
@ -1352,7 +1352,7 @@ pub(crate) struct AppStateConfig {
|
|||
pub(crate) preloaded_vault: Vault,
|
||||
pub(crate) server_secrets: ServerSecrets,
|
||||
pub(crate) env_lookup: EnvLookup,
|
||||
pub(crate) github_api_base_url: Option<String>,
|
||||
pub(crate) github_host: Option<fabro_types::GitHubHost>,
|
||||
pub(crate) active_config_path: PathBuf,
|
||||
pub(crate) http_client: Option<fabro_http::HttpClient>,
|
||||
pub(crate) sandbox_inventory: Option<SandboxInventory>,
|
||||
|
|
@ -1924,8 +1924,9 @@ pub fn build_router_with_options(
|
|||
} = options;
|
||||
let translation_state = Arc::clone(&state);
|
||||
let state_for_canonical_host = Arc::clone(&state);
|
||||
let github_endpoints =
|
||||
github_endpoints.unwrap_or_else(|| Arc::new(GithubEndpoints::production_defaults()));
|
||||
let github_endpoints = github_endpoints
|
||||
.unwrap_or_else(|| Arc::new(GithubEndpoints::from_host(&state.github_host)));
|
||||
let csp_policy = Arc::new(csp::policy(&state.github_host));
|
||||
let webhook_secret = state.github_webhook_secret.clone();
|
||||
let principal_layer = middleware::from_fn_with_state(Arc::clone(&state), principal_middleware);
|
||||
let api_common = if web_enabled {
|
||||
|
|
@ -2032,7 +2033,10 @@ pub fn build_router_with_options(
|
|||
},
|
||||
canonical_host::redirect_middleware,
|
||||
))
|
||||
.layer(middleware::from_fn(security_headers::layer))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
csp_policy,
|
||||
security_headers::layer,
|
||||
))
|
||||
.layer(middleware::from_fn(http_log_middleware))
|
||||
.layer(middleware::from_fn(request_id::layer))
|
||||
}
|
||||
|
|
@ -2519,7 +2523,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
preloaded_vault,
|
||||
server_secrets,
|
||||
env_lookup,
|
||||
github_api_base_url,
|
||||
github_host,
|
||||
active_config_path,
|
||||
http_client,
|
||||
sandbox_inventory,
|
||||
|
|
@ -2574,7 +2578,17 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
// Petri's records live on the shared pool; the view tables live where the
|
||||
// run summary store keeps the `runs` row (the same database in the
|
||||
// server, a fixture of its own in a test).
|
||||
let petri_projector = Projector::new(db_pool.clone(), store.run_summary_store().pool());
|
||||
let github_host = match github_host {
|
||||
Some(host) => host,
|
||||
None => fabro_github::github_host(
|
||||
&resolved_settings.server_settings.server.integrations.github,
|
||||
)?,
|
||||
};
|
||||
let petri_projector = Projector::with_github_host(
|
||||
db_pool.clone(),
|
||||
store.run_summary_store().pool(),
|
||||
github_host.clone(),
|
||||
);
|
||||
{
|
||||
let projector = Arc::clone(&petri_projector);
|
||||
store.set_platform_record_hook(Arc::new(move |run_id| projector.signal(run_id)));
|
||||
|
|
@ -2643,7 +2657,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
}
|
||||
};
|
||||
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
|
||||
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
|
||||
let storage_root = PathBuf::from(¤t_server_settings.server.storage.root);
|
||||
let automation_repo_cache = Arc::new(GitRepoCache::new(
|
||||
Storage::new(&storage_root)
|
||||
|
|
@ -2721,7 +2734,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
effective_web_url: RwLock::new(current_effective_web_url),
|
||||
catalog: RwLock::new(current_catalog),
|
||||
env_lookup: Arc::clone(&env_lookup),
|
||||
github_api_base_url,
|
||||
github_host,
|
||||
active_config_path,
|
||||
http_client,
|
||||
sandbox_inventory,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use axum::http::{HeaderValue, header};
|
|||
use fabro_store::platform_records::{
|
||||
PlatformRecord, PullRequestLinkedRecord, PullRequestRequestedRecord,
|
||||
};
|
||||
use fabro_types::GitHubHost;
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, CloseRunPullRequestResponse, CreateRunPullRequestRequest, IntoResponse,
|
||||
|
|
@ -47,15 +48,22 @@ const PULL_REQUEST_CREATION_RETRY_AFTER: Duration = Duration::from_secs(1);
|
|||
clippy::disallowed_types,
|
||||
reason = "Pull-request API validates public github.com URLs; these raw URLs are not credential-bearing log output."
|
||||
)]
|
||||
fn parse_github_owner_repo_from_url(url: &str, kind: &str) -> Result<(String, String), ApiError> {
|
||||
fn parse_github_owner_repo_from_url(
|
||||
host: &GitHubHost,
|
||||
url: &str,
|
||||
kind: &str,
|
||||
) -> Result<(String, String), ApiError> {
|
||||
let parsed = fabro_http::Url::parse(url)
|
||||
.map_err(|err| ApiError::bad_request(format!("Invalid {kind}: {err}")))?;
|
||||
match parsed.host_str() {
|
||||
Some("github.com") => {}
|
||||
Some(host) => {
|
||||
Some(got) if got == host.hostname() => {}
|
||||
Some(got) => {
|
||||
return Err(ApiError::with_code(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Pull request operations support github.com only (got {host})."),
|
||||
format!(
|
||||
"Pull request operations support {} only (got {got}).",
|
||||
host.host()
|
||||
),
|
||||
"unsupported_host",
|
||||
));
|
||||
}
|
||||
|
|
@ -66,19 +74,40 @@ fn parse_github_owner_repo_from_url(url: &str, kind: &str) -> Result<(String, St
|
|||
}
|
||||
}
|
||||
|
||||
fabro_github::parse_github_owner_repo(url).map_err(|err| ApiError::bad_request(err.to_string()))
|
||||
fabro_github::parse_github_owner_repo(host, url)
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "Pull-request links are public GitHub URLs; the raw URL is only compared with the configured host."
|
||||
)]
|
||||
fn pull_request_record_from_link_request(
|
||||
host: &GitHubHost,
|
||||
body: &LinkRunPullRequestRequest,
|
||||
) -> Result<PullRequestLink, ApiError> {
|
||||
PullRequestLink::from_github_url(body.html_url.trim()).map_err(|err| {
|
||||
let reject = |err: String| {
|
||||
let code = if err.contains("GitHub pull request URL") {
|
||||
"unsupported_pull_request_provider"
|
||||
} else {
|
||||
"invalid_pull_request_url"
|
||||
};
|
||||
ApiError::with_code(StatusCode::BAD_REQUEST, err, code)
|
||||
};
|
||||
let raw_url = body.html_url.trim();
|
||||
let off_host = fabro_http::Url::parse(raw_url)
|
||||
.is_ok_and(|url| url.origin().ascii_serialization() != host.web_url());
|
||||
if off_host {
|
||||
return Err(reject(format!(
|
||||
"Pull request link must be a GitHub pull request URL like {}/owner/repo/pull/123.",
|
||||
host.web_url()
|
||||
)));
|
||||
}
|
||||
let link = PullRequestLink::from_github_url(raw_url).map_err(reject)?;
|
||||
// Store the canonical URL, not the caller's spelling of it.
|
||||
Ok(PullRequestLink {
|
||||
html_url: host.pull_request_url(&link.owner, &link.repo, link.number),
|
||||
..link
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +152,7 @@ pub(in crate::server) fn server_github_context<'a>(
|
|||
})?;
|
||||
Ok(fabro_github::GitHubContext::with_http_client(
|
||||
creds,
|
||||
state.github_api_base_url.as_str(),
|
||||
&state.github_host,
|
||||
http_client,
|
||||
))
|
||||
}
|
||||
|
|
@ -272,7 +301,11 @@ impl<'a> RunPrInputs<'a> {
|
|||
));
|
||||
}
|
||||
let normalized_origin = fabro_github::normalize_repo_origin_url(origin_url);
|
||||
parse_github_owner_repo_from_url(&normalized_origin, "repo origin URL")?;
|
||||
parse_github_owner_repo_from_url(
|
||||
&state.github_host,
|
||||
&normalized_origin,
|
||||
"repo origin URL",
|
||||
)?;
|
||||
Ok(Self {
|
||||
goal: run_spec.graph.goal(),
|
||||
base_branch,
|
||||
|
|
@ -456,7 +489,7 @@ async fn link_run_pull_request(
|
|||
Json(body): Json<LinkRunPullRequestRequest>,
|
||||
) -> Response {
|
||||
let _create_guard = state.pull_request_create_locks.lock(id).await;
|
||||
let pull_request = match pull_request_record_from_link_request(&body) {
|
||||
let pull_request = match pull_request_record_from_link_request(&state.github_host, &body) {
|
||||
Ok(record) => record,
|
||||
Err(err) => return err.into_response(),
|
||||
};
|
||||
|
|
@ -612,9 +645,12 @@ async fn close_run_pull_request(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::StatusCode;
|
||||
use fabro_types::{DirtyStatus, GitContext, StartRecord, blob_ref};
|
||||
use fabro_api::types::LinkRunPullRequestRequest;
|
||||
use fabro_types::{DirtyStatus, GitContext, GitHubHost, StartRecord, blob_ref};
|
||||
|
||||
use super::RunPrInputs;
|
||||
use super::{
|
||||
RunPrInputs, parse_github_owner_repo_from_url, pull_request_record_from_link_request,
|
||||
};
|
||||
use crate::test_support::{test_app_state, test_concluded_run_projection};
|
||||
|
||||
/// A finished run with everything a pull request needs and `patch` as
|
||||
|
|
@ -679,4 +715,69 @@ mod tests {
|
|||
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(error.detail(), "The saved final patch is missing.");
|
||||
}
|
||||
|
||||
fn ghes() -> GitHubHost {
|
||||
GitHubHost::new("https://ghe.example.com", None).unwrap()
|
||||
}
|
||||
|
||||
fn link_request(html_url: &str) -> LinkRunPullRequestRequest {
|
||||
serde_json::from_value(serde_json::json!({ "html_url": html_url })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_request_accepts_the_configured_host_and_stores_the_canonical_url() {
|
||||
let link = pull_request_record_from_link_request(
|
||||
&ghes(),
|
||||
&link_request("https://GHE.example.com/acme/widgets/pull/7/"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(link.html_url, "https://ghe.example.com/acme/widgets/pull/7");
|
||||
assert_eq!(
|
||||
(link.owner.as_str(), link.repo.as_str(), link.number),
|
||||
("acme", "widgets", 7)
|
||||
);
|
||||
|
||||
let default = pull_request_record_from_link_request(
|
||||
&GitHubHost::default(),
|
||||
&link_request("https://github.com/acme/widgets/pull/7"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(default.html_url, "https://github.com/acme/widgets/pull/7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_request_rejects_other_hosts_naming_the_configured_one() {
|
||||
let error = pull_request_record_from_link_request(
|
||||
&ghes(),
|
||||
&link_request("https://github.com/acme/widgets/pull/7"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
error
|
||||
.detail()
|
||||
.contains("https://ghe.example.com/owner/repo/pull/123"),
|
||||
"{}",
|
||||
error.detail()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_origin_must_be_on_the_configured_host() {
|
||||
let (owner, repo) = parse_github_owner_repo_from_url(
|
||||
&ghes(),
|
||||
"https://ghe.example.com/acme/widgets",
|
||||
"origin",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((owner.as_str(), repo.as_str()), ("acme", "widgets"));
|
||||
|
||||
let error =
|
||||
parse_github_owner_repo_from_url(&ghes(), "https://github.com/acme/widgets", "origin")
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.detail(),
|
||||
"Pull request operations support ghe.example.com only (got github.com)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ pub(crate) async fn create_run_from_intent(
|
|||
let explicit_title_supplied = intent.title.is_some();
|
||||
// Validate the pure, in-memory request facts before paying for
|
||||
// blob-store reads and closure lowering.
|
||||
let ValidatedRunTarget { target, git } = match intent.target.validate() {
|
||||
let ValidatedRunTarget { target, git } = match intent.target.validate(&state.github_host) {
|
||||
Ok(validated) => validated,
|
||||
Err(error) => return run_intent_admission_error(error.into()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ async fn get_github_repo(
|
|||
}
|
||||
let settings = state.server_settings();
|
||||
let github_settings = &settings.server.integrations.github;
|
||||
let base_url = fabro_github::github_api_base_url();
|
||||
let base_url = state.github_host.api_url();
|
||||
let (token, client) = match github_settings.strategy {
|
||||
GithubIntegrationStrategy::App => {
|
||||
if github_settings.app_id.is_none() {
|
||||
|
|
@ -493,9 +493,14 @@ async fn get_github_repo(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
let install_url = creds.installation_url(&owner).unwrap_or_else(|| {
|
||||
format!("https://github.com/organizations/{owner}/settings/installations")
|
||||
});
|
||||
let install_url = creds
|
||||
.installation_url(&state.github_host, &owner)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}/organizations/{owner}/settings/installations",
|
||||
state.github_host.web_url()
|
||||
)
|
||||
});
|
||||
|
||||
let client = match state.http_client() {
|
||||
Ok(http) => http,
|
||||
|
|
@ -505,7 +510,7 @@ async fn get_github_repo(
|
|||
}
|
||||
};
|
||||
let installed =
|
||||
match fabro_github::check_app_installed(&client, &jwt, &owner, &name, &base_url)
|
||||
match fabro_github::check_app_installed(&client, &jwt, &owner, &name, base_url)
|
||||
.await
|
||||
{
|
||||
Ok(installed) => installed,
|
||||
|
|
@ -537,7 +542,7 @@ async fn get_github_repo(
|
|||
&jwt,
|
||||
&owner,
|
||||
&name,
|
||||
&base_url,
|
||||
&state.github_host,
|
||||
serde_json::json!({ "contents": "write", "pull_requests": "write" }),
|
||||
Some(&install_url),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -474,6 +474,7 @@ pub(crate) async fn execute(state: Arc<AppState>, run_id: RunId) {
|
|||
// The in-process path serves the server's tests: a Git target is
|
||||
// fetched without a credential, and nothing is published.
|
||||
let source = RunSource::for_run(
|
||||
&state.github_host,
|
||||
run_state.spec.target.as_ref(),
|
||||
&run_state.spec.settings.run,
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -2114,7 +2114,7 @@ fn slack_app_state_with_settings_and_secret_sources(
|
|||
preloaded_vault: vault,
|
||||
server_secrets: load_test_server_secrets(server_env_path, server_secret_env),
|
||||
env_lookup: default_env_lookup(),
|
||||
github_api_base_url: None,
|
||||
github_host: None,
|
||||
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
|
||||
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
||||
sandbox_inventory: None,
|
||||
|
|
@ -5102,7 +5102,9 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
|
|||
preloaded_vault,
|
||||
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),
|
||||
env_lookup: Arc::new(env_lookup),
|
||||
github_api_base_url,
|
||||
github_host: github_api_base_url.map(|url| {
|
||||
fabro_types::GitHubHost::new(&url, Some(&url)).expect("mock GitHub URL should be valid")
|
||||
}),
|
||||
active_config_path,
|
||||
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
||||
sandbox_inventory: None,
|
||||
|
|
@ -6302,6 +6304,60 @@ slug = "fabro"
|
|||
assert!(location.starts_with("https://github.com/login/oauth/authorize?"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_login_github_redirects_to_the_configured_enterprise_host() {
|
||||
let source = r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth]
|
||||
methods = ["github"]
|
||||
|
||||
[server.web]
|
||||
enabled = true
|
||||
url = "http://localhost:3000"
|
||||
|
||||
[server.auth.github]
|
||||
allowed_usernames = ["octocat"]
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "123"
|
||||
client_id = "Iv1.testclient"
|
||||
slug = "fabro"
|
||||
web_url = "https://ghe.example.com"
|
||||
"#;
|
||||
let app = build_router(
|
||||
test_app_state_with_session_key(
|
||||
server_settings_from_toml(source),
|
||||
manifest_run_defaults_from_toml(source),
|
||||
Some("github-redirect-test-key-0123456789"),
|
||||
),
|
||||
AuthMode::Enabled(ConfiguredAuth {
|
||||
methods: vec![ServerAuthMethod::Github],
|
||||
dev_token: None,
|
||||
jwt_key: None,
|
||||
jwt_issuer: None,
|
||||
}),
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/auth/login/github")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = checked_response!(response, StatusCode::SEE_OTHER).await;
|
||||
let location = response
|
||||
.headers()
|
||||
.get(axum::http::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap();
|
||||
assert!(location.starts_with("https://ghe.example.com/login/oauth/authorize?"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_redirects_to_login_page() {
|
||||
let app = test_app_with();
|
||||
|
|
@ -10808,7 +10864,7 @@ methods = ["dev-token"]
|
|||
preloaded_vault,
|
||||
server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(),
|
||||
env_lookup: default_env_lookup(),
|
||||
github_api_base_url: None,
|
||||
github_host: None,
|
||||
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
|
||||
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
||||
sandbox_inventory: None,
|
||||
|
|
@ -10866,7 +10922,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() {
|
|||
HashMap::new(),
|
||||
),
|
||||
env_lookup: default_env_lookup(),
|
||||
github_api_base_url: None,
|
||||
github_host: None,
|
||||
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
|
||||
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
||||
sandbox_inventory: None,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,15 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
// the vault and supplies it explicitly to the in-process provider.
|
||||
EnvVars::DAYTONA_API_URL,
|
||||
EnvVars::DAYTONA_ORGANIZATION_ID,
|
||||
// The GitHub deployment's non-secret selection. The worker's GitHub
|
||||
// client reads the web origin and API base overrides; the settings
|
||||
// reach it through FABRO_HOME.
|
||||
EnvVars::GITHUB_WEB_URL,
|
||||
EnvVars::GITHUB_BASE_URL,
|
||||
// Private CA bundle for the worker's TLS stack (rustls platform verifier;
|
||||
// honoured on Linux only), e.g. for a GitHub Enterprise Server host.
|
||||
EnvVars::SSL_CERT_FILE,
|
||||
EnvVars::SSL_CERT_DIR,
|
||||
// A test's checkpoint gates: the worker's hooks hold at a named point
|
||||
// until the test releases them, so a crash can be placed there.
|
||||
EnvVars::FABRO_TEST_CHECKPOINT_GATES,
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ impl TestAppStateBuilder {
|
|||
preloaded_vault,
|
||||
server_secrets: load_test_server_secrets(server_env_path, self.server_secret_env),
|
||||
env_lookup: self.env_lookup,
|
||||
github_api_base_url: None,
|
||||
github_host: None,
|
||||
active_config_path,
|
||||
http_client: Some(
|
||||
fabro_http::test_http_client().expect("test HTTP client should build"),
|
||||
|
|
|
|||
|
|
@ -762,12 +762,12 @@ async fn callback_github(
|
|||
v: 2,
|
||||
login: profile.login.clone(),
|
||||
auth_method: AuthMethod::Github,
|
||||
identity: IdpIdentity::new("https://github.com", profile.id.to_string())
|
||||
identity: IdpIdentity::new(state.github_host.web_url(), profile.id.to_string())
|
||||
.expect("GitHub profile id should produce a valid identity"),
|
||||
name: profile.name.unwrap_or_else(|| profile.login.clone()),
|
||||
email: primary_email,
|
||||
avatar_url: profile.avatar_url,
|
||||
user_url: format!("https://github.com/{}", profile.login),
|
||||
user_url: format!("{}/{}", state.github_host.web_url(), profile.login),
|
||||
iat: now.timestamp(),
|
||||
exp: (now + chrono::Duration::days(30)).timestamp(),
|
||||
};
|
||||
|
|
@ -997,7 +997,7 @@ mod tests {
|
|||
use fabro_config::{RunLayer, ServerSettingsBuilder};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::server::ServerAuthMethod;
|
||||
use fabro_types::{AuthMethod, IdpIdentity, Principal};
|
||||
use fabro_types::{AuthMethod, GitHubHost, IdpIdentity, Principal};
|
||||
use fabro_vault::SecretType;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -1109,7 +1109,9 @@ client_id = "github-client-id"
|
|||
translation_state,
|
||||
crate::auth::auth_translation_middleware,
|
||||
))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::from_host(
|
||||
&GitHubHost::default(),
|
||||
))))
|
||||
.layer(Extension(auth_mode))
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
@ -1130,7 +1132,9 @@ client_id = "github-client-id"
|
|||
Arc::clone(&captured),
|
||||
crate::test_support::capture_auth_context,
|
||||
))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
|
||||
.layer(Extension(Arc::new(GithubEndpoints::from_host(
|
||||
&GitHubHost::default(),
|
||||
))))
|
||||
.layer(Extension(auth_mode))
|
||||
.with_state(state);
|
||||
(app, captured)
|
||||
|
|
|
|||
|
|
@ -142,8 +142,7 @@ fn legacy_target(
|
|||
tag,
|
||||
sha,
|
||||
})
|
||||
.validate()
|
||||
.map(|validated| validated.target)
|
||||
.canonical()
|
||||
.map_err(|source| AutomationStoreError::LegacyTarget {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -166,8 +166,7 @@ pub fn validate_workflow_source(
|
|||
source: AutomationGitWorkflowSource,
|
||||
) -> Result<AutomationGitWorkflowSource, AutomationValidationError> {
|
||||
source
|
||||
.validate()
|
||||
.map(fabro_types::ValidatedGitRunTarget::into_target)
|
||||
.canonical()
|
||||
.map_err(|source| AutomationValidationError::InvalidWorkflowSource { source })
|
||||
}
|
||||
|
||||
|
|
@ -404,8 +403,7 @@ fn validate_target(target: RunTarget) -> Result<RunTarget, AutomationValidationE
|
|||
});
|
||||
}
|
||||
target
|
||||
.validate()
|
||||
.map(|validated| validated.target)
|
||||
.canonical()
|
||||
.map_err(|source| AutomationValidationError::InvalidTarget { source })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use fabro_types::GitHubRepositorySlug;
|
||||
use fabro_types::settings::run::RunIntegrationsGithubSettings;
|
||||
use fabro_types::{GitHubHost, GitHubRepositorySlug};
|
||||
use futures::stream::{self, StreamExt as _};
|
||||
|
||||
use crate::{GitHubAppCredentials, HttpClient, InstallationLookup, InstallationToken};
|
||||
|
|
@ -48,6 +48,7 @@ impl GitHubRepositoryAccess {
|
|||
/// - a declared additional set requires interpolated permissions with
|
||||
/// `contents = "read"` or `contents = "write"`.
|
||||
pub fn new(
|
||||
host: &GitHubHost,
|
||||
origin_url: Option<&str>,
|
||||
additional_repositories: &BTreeSet<GitHubRepositorySlug>,
|
||||
permissions: HashMap<String, String>,
|
||||
|
|
@ -64,7 +65,7 @@ impl GitHubRepositoryAccess {
|
|||
};
|
||||
|
||||
let normalized = crate::normalize_repo_origin_url(origin_url);
|
||||
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
|
||||
let (owner, repo) = crate::parse_github_owner_repo(host, &normalized)
|
||||
.context("parsing GitHub origin for repository access")?;
|
||||
let Some(primary) = GitHubRepositorySlug::try_new(&format!("{owner}/{repo}")) else {
|
||||
bail!("run origin does not name a valid GitHub `owner/repository`: {owner}/{repo}");
|
||||
|
|
@ -257,7 +258,12 @@ mod tests {
|
|||
additional: &[&str],
|
||||
permissions: HashMap<String, String>,
|
||||
) -> anyhow::Result<Option<GitHubRepositoryAccess>> {
|
||||
GitHubRepositoryAccess::new(Some(origin), &slugs(additional), permissions)
|
||||
GitHubRepositoryAccess::new(
|
||||
&GitHubHost::default(),
|
||||
Some(origin),
|
||||
&slugs(additional),
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -278,19 +284,34 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn no_origin_and_no_additional_repositories_is_none() {
|
||||
let access = GitHubRepositoryAccess::new(None, &BTreeSet::new(), HashMap::new()).unwrap();
|
||||
let access = GitHubRepositoryAccess::new(
|
||||
&GitHubHost::default(),
|
||||
None,
|
||||
&BTreeSet::new(),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(access.is_none());
|
||||
|
||||
let blank =
|
||||
GitHubRepositoryAccess::new(Some(" "), &BTreeSet::new(), HashMap::new()).unwrap();
|
||||
let blank = GitHubRepositoryAccess::new(
|
||||
&GitHubHost::default(),
|
||||
Some(" "),
|
||||
&BTreeSet::new(),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(blank.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_repositories_require_an_origin() {
|
||||
let err =
|
||||
GitHubRepositoryAccess::new(None, &slugs(&["fabro-sh/keystone"]), contents_read())
|
||||
.unwrap_err();
|
||||
let err = GitHubRepositoryAccess::new(
|
||||
&GitHubHost::default(),
|
||||
None,
|
||||
&slugs(&["fabro-sh/keystone"]),
|
||||
contents_read(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("requires a GitHub run origin"),
|
||||
"{err:#}"
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
//!
|
||||
//! A run's commits carry the identity of the credential the run uses:
|
||||
//! the App's bot account (`<slug>[bot]`) or the authenticated user of a
|
||||
//! personal access token. Both resolve to a stable GitHub.com noreply
|
||||
//! address, `{id}+{login}@users.noreply.github.com`, so GitHub attributes
|
||||
//! personal access token. Both resolve to a stable noreply
|
||||
//! address, `{id}+{login}@users.noreply.<host>`, so GitHub attributes
|
||||
//! the commits to that account without a private email.
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use fabro_types::GitHubHost;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{GitHubAppCredentials, HttpClient, HttpMethod, github_headers, sign_app_jwt};
|
||||
|
|
@ -21,10 +22,10 @@ pub struct GitHubAccountIdentity {
|
|||
}
|
||||
|
||||
impl GitHubAccountIdentity {
|
||||
/// The GitHub.com noreply address GitHub attributes to this account.
|
||||
/// The noreply address `host` attributes to this account.
|
||||
#[must_use]
|
||||
pub fn noreply_email(&self) -> String {
|
||||
format!("{}+{}@users.noreply.github.com", self.id, self.login)
|
||||
pub fn noreply_email(&self, host: &GitHubHost) -> String {
|
||||
format!("{}+{}@{}", self.id, self.login, host.noreply_email_domain())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +154,24 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noreply_email_uses_the_hosts_domain() {
|
||||
let identity = GitHubAccountIdentity {
|
||||
login: "octo".to_string(),
|
||||
id: 7,
|
||||
};
|
||||
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
assert_eq!(
|
||||
identity.noreply_email(&ghes),
|
||||
"7+octo@users.noreply.ghe.example.com"
|
||||
);
|
||||
let ghec = GitHubHost::new("https://octocorp.ghe.com", None).unwrap();
|
||||
assert_eq!(
|
||||
identity.noreply_email(&ghec),
|
||||
"7+octo@users.noreply.octocorp.ghe.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noreply_email_uses_id_plus_login() {
|
||||
let identity = GitHubAccountIdentity {
|
||||
|
|
@ -160,7 +179,7 @@ mod tests {
|
|||
id: 281_434_857,
|
||||
};
|
||||
assert_eq!(
|
||||
identity.noreply_email(),
|
||||
identity.noreply_email(&GitHubHost::default()),
|
||||
"281434857+fabro-sh[bot]@users.noreply.github.com"
|
||||
);
|
||||
}
|
||||
|
|
@ -251,7 +270,7 @@ mod tests {
|
|||
assert_eq!(identity.login, "my-app[bot]");
|
||||
assert_eq!(identity.id, 4242);
|
||||
assert_eq!(
|
||||
identity.noreply_email(),
|
||||
identity.noreply_email(&GitHubHost::default()),
|
||||
"4242+my-app[bot]@users.noreply.github.com"
|
||||
);
|
||||
// The configured slug skips the `/app` lookup entirely.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::{Context as _, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::GitHubHost;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{GitHubAppCredentials, GitHubCredentials, InstallationToken};
|
||||
|
|
@ -162,10 +163,10 @@ enum AppTokenScope {
|
|||
|
||||
/// Real minter backed by GitHub App credentials.
|
||||
struct AppTokenMinter {
|
||||
creds: GitHubAppCredentials,
|
||||
http: fabro_http::HttpClient,
|
||||
base_url: String,
|
||||
scope: AppTokenScope,
|
||||
creds: GitHubAppCredentials,
|
||||
http: fabro_http::HttpClient,
|
||||
host: GitHubHost,
|
||||
scope: AppTokenScope,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -182,7 +183,7 @@ impl InstallationTokenMinter for AppTokenMinter {
|
|||
&self.http,
|
||||
owner,
|
||||
repo,
|
||||
&self.base_url,
|
||||
&self.host,
|
||||
permissions.clone(),
|
||||
None,
|
||||
)
|
||||
|
|
@ -190,7 +191,7 @@ impl InstallationTokenMinter for AppTokenMinter {
|
|||
}
|
||||
AppTokenScope::Access(access) => {
|
||||
access
|
||||
.mint_installation_token(&self.creds, &self.http, &self.base_url)
|
||||
.mint_installation_token(&self.creds, &self.http, self.host.api_url())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
@ -263,24 +264,26 @@ impl InstallationTokenSource {
|
|||
/// pass through and ignore it.
|
||||
pub fn for_origin(
|
||||
creds: &GitHubCredentials,
|
||||
host: &GitHubHost,
|
||||
origin_url: &str,
|
||||
permissions: serde_json::Value,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let normalized = crate::normalize_repo_origin_url(origin_url);
|
||||
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
|
||||
let (owner, repo) = crate::parse_github_owner_repo(host, &normalized)
|
||||
.context("parsing GitHub origin for token source")?;
|
||||
Self::for_repository(creds, owner, repo, permissions)
|
||||
Self::for_repository(creds, host, owner, repo, permissions)
|
||||
}
|
||||
|
||||
/// Build a source for an already parsed GitHub repository.
|
||||
pub fn for_repository(
|
||||
creds: &GitHubCredentials,
|
||||
host: &GitHubHost,
|
||||
owner: String,
|
||||
repo: String,
|
||||
permissions: serde_json::Value,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let repo_display = format!("{owner}/{repo}");
|
||||
Self::with_app_scope(creds, repo_display, AppTokenScope::Repository {
|
||||
Self::with_app_scope(creds, host, repo_display, AppTokenScope::Repository {
|
||||
owner,
|
||||
repo,
|
||||
permissions,
|
||||
|
|
@ -294,15 +297,22 @@ impl InstallationTokenSource {
|
|||
/// single-flight behavior are identical to the single-repository source.
|
||||
pub fn for_access(
|
||||
creds: &GitHubCredentials,
|
||||
host: &GitHubHost,
|
||||
access: &crate::GitHubRepositoryAccess,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let repository_names = access.repository_names();
|
||||
let repo_display = repository_set_display(access.owner(), &repository_names)?;
|
||||
Self::with_app_scope(creds, repo_display, AppTokenScope::Access(access.clone()))
|
||||
Self::with_app_scope(
|
||||
creds,
|
||||
host,
|
||||
repo_display,
|
||||
AppTokenScope::Access(access.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
fn with_app_scope(
|
||||
creds: &GitHubCredentials,
|
||||
host: &GitHubHost,
|
||||
repo_display: String,
|
||||
scope: AppTokenScope,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
|
|
@ -317,7 +327,7 @@ impl InstallationTokenSource {
|
|||
minter: Box::new(AppTokenMinter {
|
||||
creds: app.clone(),
|
||||
http,
|
||||
base_url: crate::github_api_base_url(),
|
||||
host: host.clone(),
|
||||
scope,
|
||||
}),
|
||||
cache: Mutex::new(None),
|
||||
|
|
@ -550,6 +560,7 @@ mod tests {
|
|||
async fn pat_resolves_as_static_generation_zero() {
|
||||
let source = InstallationTokenSource::for_origin(
|
||||
&GitHubCredentials::Pat("ghp_pat".to_string()),
|
||||
&GitHubHost::default(),
|
||||
"https://github.com/owner/repo.git",
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
|
|
@ -569,6 +580,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn for_access_source_resolves_like_the_single_repository_source() {
|
||||
let access = crate::GitHubRepositoryAccess::new(
|
||||
&GitHubHost::default(),
|
||||
Some("https://github.com/owner/repo.git"),
|
||||
&["owner/keystone".parse().unwrap()].into_iter().collect(),
|
||||
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
|
||||
|
|
@ -578,6 +590,7 @@ mod tests {
|
|||
|
||||
let source = InstallationTokenSource::for_access(
|
||||
&GitHubCredentials::Pat("ghp_pat".to_string()),
|
||||
&GitHubHost::default(),
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -594,6 +607,7 @@ mod tests {
|
|||
token: "ghs_static".to_string(),
|
||||
expires_at: Utc::now() + chrono::Duration::minutes(30),
|
||||
}),
|
||||
&GitHubHost::default(),
|
||||
"https://github.com/owner/repo.git",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
|
|
@ -607,6 +621,7 @@ mod tests {
|
|||
token: "ghs_expired".to_string(),
|
||||
expires_at: Utc::now() - chrono::Duration::seconds(1),
|
||||
}),
|
||||
&GitHubHost::default(),
|
||||
"https://github.com/owner/repo.git",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,10 +4,15 @@ use fabro_github::{
|
|||
get_pull_request, merge_pull_request, resolve_authenticated_url, sign_app_jwt,
|
||||
};
|
||||
use fabro_test::{GitHubAppOptions, GitHubAppState, TwinGitHub};
|
||||
use fabro_types::GitHubHost;
|
||||
use fabro_types::settings::run::MergeStrategy;
|
||||
|
||||
const TEST_RSA_KEY: &str = include_str!("../src/testdata/rsa_private.pem");
|
||||
|
||||
fn twin_host(twin: &TwinGitHub) -> GitHubHost {
|
||||
GitHubHost::new(&twin.base_url, Some(&twin.base_url)).expect("twin URL should be a valid host")
|
||||
}
|
||||
|
||||
fn github_credentials() -> GitHubCredentials {
|
||||
GitHubCredentials::App(GitHubAppCredentials {
|
||||
app_id: "42".to_string(),
|
||||
|
|
@ -40,7 +45,8 @@ fn standard_app_state() -> GitHubAppState {
|
|||
async fn create_and_get_pull_request() {
|
||||
let twin = TwinGitHub::start(standard_app_state()).await;
|
||||
let creds = github_credentials();
|
||||
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
||||
let host = twin_host(&twin);
|
||||
let ctx = &GitHubContext::new(&creds, &host);
|
||||
|
||||
let created = create_pull_request(
|
||||
ctx,
|
||||
|
|
@ -71,7 +77,8 @@ async fn create_and_get_pull_request() {
|
|||
async fn create_merge_and_verify_state() {
|
||||
let twin = TwinGitHub::start(standard_app_state()).await;
|
||||
let creds = github_credentials();
|
||||
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
||||
let host = twin_host(&twin);
|
||||
let ctx = &GitHubContext::new(&creds, &host);
|
||||
|
||||
let created = create_pull_request(
|
||||
ctx, "acme", "widgets", "main", "feature", "Merge me", "PR body", false,
|
||||
|
|
@ -104,7 +111,8 @@ async fn create_close_and_verify_state() {
|
|||
let twin = TwinGitHub::start(standard_app_state()).await;
|
||||
let creds = github_credentials();
|
||||
|
||||
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
||||
let host = twin_host(&twin);
|
||||
let ctx = &GitHubContext::new(&creds, &host);
|
||||
|
||||
let created = create_pull_request(
|
||||
ctx, "acme", "widgets", "main", "feature", "Close me", "PR body", false,
|
||||
|
|
@ -130,7 +138,8 @@ async fn enable_auto_merge_persists() {
|
|||
let twin = TwinGitHub::start(standard_app_state()).await;
|
||||
let creds = github_credentials();
|
||||
|
||||
let ctx = &GitHubContext::new(&creds, &twin.base_url);
|
||||
let host = twin_host(&twin);
|
||||
let ctx = &GitHubContext::new(&creds, &host);
|
||||
|
||||
let created = create_pull_request(
|
||||
ctx,
|
||||
|
|
@ -160,10 +169,9 @@ async fn enable_auto_merge_persists() {
|
|||
};
|
||||
let jwt = sign_app_jwt(&app_creds.app_id, &app_creds.private_key_pem).unwrap();
|
||||
let client = fabro_test::test_http_client();
|
||||
let token =
|
||||
create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &twin.base_url)
|
||||
.await
|
||||
.unwrap();
|
||||
let token = create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &host)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let detail: serde_json::Value = fabro_test::test_http_client()
|
||||
.get(format!(
|
||||
|
|
@ -192,9 +200,10 @@ async fn enable_auto_merge_persists() {
|
|||
async fn resolve_authenticated_url_embeds_token() {
|
||||
let twin = TwinGitHub::start(standard_app_state()).await;
|
||||
let creds = github_credentials();
|
||||
let host = twin_host(&twin);
|
||||
|
||||
let url = resolve_authenticated_url(
|
||||
&GitHubContext::new(&creds, &twin.base_url),
|
||||
&GitHubContext::new(&creds, &host),
|
||||
"https://github.com/acme/widgets.git",
|
||||
)
|
||||
.await
|
||||
|
|
@ -214,9 +223,10 @@ async fn resolve_authenticated_url_embeds_token() {
|
|||
async fn resolve_authenticated_url_errors_on_non_github_url() {
|
||||
let twin = TwinGitHub::start(standard_app_state()).await;
|
||||
let creds = github_credentials();
|
||||
let host = twin_host(&twin);
|
||||
|
||||
let error = resolve_authenticated_url(
|
||||
&GitHubContext::new(&creds, &twin.base_url),
|
||||
&GitHubContext::new(&creds, &host),
|
||||
"https://gitlab.com/foo/bar",
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use std::time::Duration;
|
|||
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_github::{GitHubAppCredentials, GitHubCredentials, GitHubRepositoryAccess};
|
||||
use fabro_types::GitHubRepositorySlug;
|
||||
use fabro_types::{GitHubHost, GitHubRepositorySlug};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::sleep;
|
||||
|
||||
|
|
@ -34,9 +34,9 @@ fn env_var(name: &str) -> String {
|
|||
}
|
||||
|
||||
async fn ls_remote_with_token(slug: &GitHubRepositorySlug, token: &str) -> bool {
|
||||
let url = slug.https_url();
|
||||
let url = slug.https_url(&GitHubHost::default());
|
||||
let mut command = Command::new("git");
|
||||
fabro_github::apply_probe_git_env(&mut command, token);
|
||||
fabro_github::apply_probe_git_env(&mut command, &GitHubHost::default(), token);
|
||||
let output = command
|
||||
.args(["ls-remote", &url, "HEAD"])
|
||||
.stdout(Stdio::null())
|
||||
|
|
@ -65,6 +65,7 @@ async fn scoped_token_reaches_the_declared_additional_repository() {
|
|||
let additional_set: BTreeSet<GitHubRepositorySlug> = [additional.clone()].into_iter().collect();
|
||||
|
||||
let access = GitHubRepositoryAccess::new(
|
||||
&GitHubHost::default(),
|
||||
Some(&origin),
|
||||
&additional_set,
|
||||
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
|
||||
|
|
@ -75,8 +76,8 @@ async fn scoped_token_reaches_the_declared_additional_repository() {
|
|||
// The production choreography: every target resolves to one shared
|
||||
// installation, then one mint scoped to the whole effective set.
|
||||
let creds = GitHubCredentials::App(app.clone());
|
||||
let source =
|
||||
InstallationTokenSource::for_access(&creds, &access).expect("token source should build");
|
||||
let source = InstallationTokenSource::for_access(&creds, &GitHubHost::default(), &access)
|
||||
.expect("token source should build");
|
||||
let resolved = source.resolve().await.expect("scoped mint should succeed");
|
||||
let token = resolved.token.expose();
|
||||
|
||||
|
|
@ -102,15 +103,19 @@ async fn scoped_token_reaches_the_declared_additional_repository() {
|
|||
// read the additional repository (proves server-side scoping, not just
|
||||
// possession of a token).
|
||||
let primary_only = GitHubRepositoryAccess::new(
|
||||
&GitHubHost::default(),
|
||||
Some(&origin),
|
||||
&BTreeSet::new(),
|
||||
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
|
||||
)
|
||||
.expect("primary-only access should validate")
|
||||
.expect("origin should produce an access value");
|
||||
let narrow_source =
|
||||
InstallationTokenSource::for_access(&GitHubCredentials::App(app), &primary_only)
|
||||
.expect("primary-only token source should build");
|
||||
let narrow_source = InstallationTokenSource::for_access(
|
||||
&GitHubCredentials::App(app),
|
||||
&GitHubHost::default(),
|
||||
&primary_only,
|
||||
)
|
||||
.expect("primary-only token source should build");
|
||||
let narrow = narrow_source
|
||||
.resolve()
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ use fabro_template::validate_static_reference;
|
|||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
|
||||
use fabro_types::{
|
||||
DirtyStatus, GitContext, GitHubRepositorySlug, GitRunTarget, ManifestPath, ReferenceKind,
|
||||
RunTarget, SandboxProviderKind, WorkflowSettings,
|
||||
DirtyStatus, GitContext, GitHubHost, GitHubRepositorySlug, GitRunTarget, ManifestPath,
|
||||
ReferenceKind, RunTarget, SandboxProviderKind, WorkflowSettings,
|
||||
};
|
||||
use fabro_workflow::git::{self, GitSyncStatus};
|
||||
pub use fabro_workflow_version::CollectedWorkflowClosure;
|
||||
|
|
@ -60,6 +60,9 @@ pub struct ManifestBuildInput {
|
|||
/// Path to the user settings file (for inclusion in
|
||||
/// `RunManifest.configs`). `None` skips the user config entry.
|
||||
pub user_settings_path: Option<PathBuf>,
|
||||
/// The GitHub deployment `run.scm` repositories and git origins are
|
||||
/// resolved against; github.com unless the caller configures another.
|
||||
pub github_host: GitHubHost,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -223,7 +226,8 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
&working_directory,
|
||||
)?;
|
||||
|
||||
let configured_repo_origin_url = configured_repo_origin_url(&workflow_settings);
|
||||
let configured_repo_origin_url =
|
||||
configured_repo_origin_url(&input.github_host, &workflow_settings);
|
||||
let git = build_legacy_git_context(&working_directory, configured_repo_origin_url.as_deref());
|
||||
let args = input.args.filter(|args| !manifest_args_is_empty(args));
|
||||
|
||||
|
|
@ -357,13 +361,17 @@ pub enum ExactCommitStatus {
|
|||
/// branch so clone-based execution can resolve the observed commit.
|
||||
#[must_use]
|
||||
pub fn observe_git_run_target(
|
||||
host: &GitHubHost,
|
||||
repo_path: &Path,
|
||||
configured_repo_origin_url: Option<&str>,
|
||||
) -> Option<GitRunTargetObservation> {
|
||||
let local = inspect_local_git(repo_path, configured_repo_origin_url)?;
|
||||
let legacy_git_context = local.legacy_git_context;
|
||||
let mut run_target =
|
||||
github_run_target(&legacy_git_context.origin_url, &legacy_git_context.branch);
|
||||
let mut run_target = github_run_target(
|
||||
host,
|
||||
&legacy_git_context.origin_url,
|
||||
&legacy_git_context.branch,
|
||||
);
|
||||
let mut exact_commit = ExactCommitStatus::Unpublished;
|
||||
if let Some(target) = run_target.as_mut() {
|
||||
let publish_status = publish_manifest_branch_best_effort(
|
||||
|
|
@ -459,6 +467,7 @@ pub enum RunTargetDerivationError {
|
|||
/// Returns the reason a clone-based target could not be derived; every
|
||||
/// variant's message is written for the CLI caller.
|
||||
pub fn derive_run_target_for_provider(
|
||||
host: &GitHubHost,
|
||||
provider: &SandboxProviderKind,
|
||||
canonical_cwd: &Path,
|
||||
configured_repo_origin_url: Option<&str>,
|
||||
|
|
@ -476,7 +485,7 @@ pub fn derive_run_target_for_provider(
|
|||
dirty_worktree: false,
|
||||
});
|
||||
}
|
||||
let Some(observation) = observe_git_run_target(canonical_cwd, configured_repo_origin_url)
|
||||
let Some(observation) = observe_git_run_target(host, canonical_cwd, configured_repo_origin_url)
|
||||
else {
|
||||
return Ok(DerivedRunTarget {
|
||||
target: none_target_for_unversioned_directory(canonical_cwd)?,
|
||||
|
|
@ -546,6 +555,7 @@ fn none_target_for_unversioned_directory(
|
|||
///
|
||||
/// Returns an error when either config exists but cannot be read or parsed.
|
||||
pub fn configured_repo_origin_url_for_location(
|
||||
host: &GitHubHost,
|
||||
location: &WorkflowLocation,
|
||||
) -> Result<Option<String>> {
|
||||
let workflow = location
|
||||
|
|
@ -565,7 +575,7 @@ pub fn configured_repo_origin_url_for_location(
|
|||
repository: workflow.repository.or(project.repository),
|
||||
github: workflow.github.or(project.github),
|
||||
};
|
||||
Ok(configured_repo_origin_url_from_scm_layer(&scm))
|
||||
Ok(configured_repo_origin_url_from_scm_layer(host, &scm))
|
||||
}
|
||||
|
||||
struct LocalGitObservation {
|
||||
|
|
@ -623,8 +633,8 @@ fn build_legacy_git_context(
|
|||
Some(local.legacy_git_context)
|
||||
}
|
||||
|
||||
fn github_run_target(origin_url: &str, branch: &str) -> Option<GitRunTarget> {
|
||||
let (owner, repository) = fabro_github::parse_github_owner_repo(origin_url).ok()?;
|
||||
fn github_run_target(host: &GitHubHost, origin_url: &str, branch: &str) -> Option<GitRunTarget> {
|
||||
let (owner, repository) = fabro_github::parse_github_owner_repo(host, origin_url).ok()?;
|
||||
let slug = GitHubRepositorySlug::try_new(&format!("{owner}/{repository}"))?;
|
||||
let validated = RunTarget::Git(GitRunTarget {
|
||||
repo: slug.to_string(),
|
||||
|
|
@ -632,7 +642,7 @@ fn github_run_target(origin_url: &str, branch: &str) -> Option<GitRunTarget> {
|
|||
tag: None,
|
||||
sha: None,
|
||||
})
|
||||
.validate()
|
||||
.validate(host)
|
||||
.ok()?;
|
||||
let RunTarget::Git(target) = validated.target else {
|
||||
unreachable!("a validated Git target must remain a Git target")
|
||||
|
|
@ -640,9 +650,10 @@ fn github_run_target(origin_url: &str, branch: &str) -> Option<GitRunTarget> {
|
|||
Some(target)
|
||||
}
|
||||
|
||||
fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
|
||||
fn configured_repo_origin_url(host: &GitHubHost, settings: &WorkflowSettings) -> Option<String> {
|
||||
let scm = &settings.run.scm;
|
||||
configured_repo_origin_url_from_scm(
|
||||
host,
|
||||
scm.provider.as_deref(),
|
||||
scm.owner.as_deref(),
|
||||
scm.repository.as_deref(),
|
||||
|
|
@ -650,6 +661,7 @@ fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
|
|||
}
|
||||
|
||||
fn configured_repo_origin_url_from_scm(
|
||||
host: &GitHubHost,
|
||||
provider: Option<&str>,
|
||||
owner: Option<&str>,
|
||||
repository: Option<&str>,
|
||||
|
|
@ -662,13 +674,17 @@ fn configured_repo_origin_url_from_scm(
|
|||
if owner.trim().is_empty() || repository.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let origin = format!("https://github.com/{owner}/{repository}");
|
||||
let origin = host.repository_url(owner, repository);
|
||||
let normalized = fabro_github::normalize_repo_origin_url(&origin);
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
}
|
||||
|
||||
fn configured_repo_origin_url_from_scm_layer(scm: &RunScmLayer) -> Option<String> {
|
||||
fn configured_repo_origin_url_from_scm_layer(
|
||||
host: &GitHubHost,
|
||||
scm: &RunScmLayer,
|
||||
) -> Option<String> {
|
||||
configured_repo_origin_url_from_scm(
|
||||
host,
|
||||
scm.provider.as_deref(),
|
||||
scm.owner.as_deref(),
|
||||
scm.repository.as_deref(),
|
||||
|
|
@ -865,6 +881,36 @@ pub(crate) mod test_fixtures {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn scm_origin_and_run_target_follow_the_configured_host() {
|
||||
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
assert_eq!(
|
||||
configured_repo_origin_url_from_scm(
|
||||
&ghes,
|
||||
Some("github"),
|
||||
Some("acme"),
|
||||
Some("widgets")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://ghe.example.com/acme/widgets")
|
||||
);
|
||||
assert_eq!(
|
||||
configured_repo_origin_url_from_scm(
|
||||
&GitHubHost::default(),
|
||||
None,
|
||||
Some("acme"),
|
||||
Some("widgets")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://github.com/acme/widgets")
|
||||
);
|
||||
|
||||
let target =
|
||||
github_run_target(&ghes, "https://ghe.example.com/acme/widgets", "main").unwrap();
|
||||
assert_eq!(target.repo, "acme/widgets");
|
||||
assert!(github_run_target(&ghes, "https://github.com/acme/widgets", "main").is_none());
|
||||
}
|
||||
|
||||
use fabro_workflow::git::head_sha;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -2050,8 +2096,12 @@ exit 1
|
|||
]);
|
||||
run_git(&workspace, &["push", "origin", "feature"]);
|
||||
|
||||
let observation =
|
||||
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
|
||||
let observation = observe_git_run_target(
|
||||
&GitHubHost::default(),
|
||||
&workspace,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
)
|
||||
.unwrap();
|
||||
let target = observation.run_target.as_ref().unwrap();
|
||||
let legacy = &observation.legacy_git_context;
|
||||
|
||||
|
|
@ -2077,8 +2127,12 @@ exit 1
|
|||
"https://github.com/acme/widgets",
|
||||
]);
|
||||
|
||||
let observation =
|
||||
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
|
||||
let observation = observe_git_run_target(
|
||||
&GitHubHost::default(),
|
||||
&workspace,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
)
|
||||
.unwrap();
|
||||
let target = observation.run_target.as_ref().unwrap();
|
||||
|
||||
assert_eq!(target.sha, observation.legacy_git_context.sha);
|
||||
|
|
@ -2112,8 +2166,12 @@ exit 1
|
|||
]);
|
||||
mark_origin_branch_synced(&workspace, "feature");
|
||||
|
||||
let observation =
|
||||
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
|
||||
let observation = observe_git_run_target(
|
||||
&GitHubHost::default(),
|
||||
&workspace,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
)
|
||||
.unwrap();
|
||||
let target = observation.run_target.as_ref().unwrap();
|
||||
|
||||
assert_eq!(target.sha, None);
|
||||
|
|
@ -2138,7 +2196,8 @@ exit 1
|
|||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
init_git_repo(&workspace, branch, "https://github.com/acme/widgets");
|
||||
|
||||
let observation = observe_git_run_target(&workspace, None).unwrap();
|
||||
let observation =
|
||||
observe_git_run_target(&GitHubHost::default(), &workspace, None).unwrap();
|
||||
|
||||
assert_eq!(observation.run_target, None, "branch {branch}");
|
||||
assert_eq!(observation.legacy_git_context.branch, branch);
|
||||
|
|
@ -2162,9 +2221,12 @@ exit 1
|
|||
"https://github.com/acme/widgets",
|
||||
]);
|
||||
|
||||
let failed =
|
||||
observe_git_run_target(&failed_workspace, Some("https://github.com/acme/widgets"))
|
||||
.unwrap();
|
||||
let failed = observe_git_run_target(
|
||||
&GitHubHost::default(),
|
||||
&failed_workspace,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(failed.run_target.as_ref().unwrap().sha, None);
|
||||
assert!(failed.legacy_git_context.sha.is_some());
|
||||
assert_eq!(failed.legacy_git_context.dirty, DirtyStatus::Clean);
|
||||
|
|
@ -2179,6 +2241,7 @@ exit 1
|
|||
);
|
||||
|
||||
let mismatched = observe_git_run_target(
|
||||
&GitHubHost::default(),
|
||||
&mismatched_workspace,
|
||||
Some("https://github.com/acme/configured"),
|
||||
)
|
||||
|
|
@ -2198,7 +2261,7 @@ exit 1
|
|||
init_git_repo(&workspace, "feature", bare_origin.to_str().unwrap());
|
||||
std::fs::write(workspace.join("dirty.txt"), "dirty").unwrap();
|
||||
|
||||
let unsupported = observe_git_run_target(&workspace, None).unwrap();
|
||||
let unsupported = observe_git_run_target(&GitHubHost::default(), &workspace, None).unwrap();
|
||||
assert!(unsupported.run_target.is_none());
|
||||
assert_eq!(unsupported.legacy_git_context.dirty, DirtyStatus::Dirty);
|
||||
assert_eq!(
|
||||
|
|
@ -2208,15 +2271,15 @@ exit 1
|
|||
|
||||
let not_repo = temp.path().join("not-repo");
|
||||
std::fs::create_dir_all(¬_repo).unwrap();
|
||||
assert!(observe_git_run_target(¬_repo, None).is_none());
|
||||
assert!(observe_git_run_target(&GitHubHost::default(), ¬_repo, None).is_none());
|
||||
|
||||
let unborn = temp.path().join("unborn");
|
||||
std::fs::create_dir_all(&unborn).unwrap();
|
||||
run_git(&unborn, &["init", "--quiet"]);
|
||||
assert!(observe_git_run_target(&unborn, None).is_none());
|
||||
assert!(observe_git_run_target(&GitHubHost::default(), &unborn, None).is_none());
|
||||
|
||||
run_git(&workspace, &["checkout", "--detach", "--quiet"]);
|
||||
assert!(observe_git_run_target(&workspace, None).is_none());
|
||||
assert!(observe_git_run_target(&GitHubHost::default(), &workspace, None).is_none());
|
||||
}
|
||||
|
||||
fn init_git_repo(path: &Path, branch: &str, origin_url: &str) {
|
||||
|
|
|
|||
|
|
@ -148,9 +148,10 @@ impl RunView {
|
|||
}
|
||||
PlatformRecord::PullRequestCreated(record) => {
|
||||
let link = PullRequestLink {
|
||||
owner: record.owner.clone(),
|
||||
repo: record.repo.clone(),
|
||||
number: record.number,
|
||||
owner: record.owner.clone(),
|
||||
repo: record.repo.clone(),
|
||||
number: record.number,
|
||||
html_url: record.html_url.clone(),
|
||||
};
|
||||
projection.pull_request = Some(link.clone());
|
||||
if let Some(creation) = projection
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ use std::time::Duration;
|
|||
use fabro_db::DbPool;
|
||||
use fabro_store::platform_records::{PlatformRecordStore, now_ms};
|
||||
use fabro_store::{RunProjection, RunSummaryStore};
|
||||
use fabro_types::{RunId, RunStreamItem};
|
||||
use fabro_types::{GitHubHost, RunId, RunStreamItem};
|
||||
use fabro_util::error::collect_chain;
|
||||
use fabro_util::sync;
|
||||
use petri_execution::events::{EventId, EventSource, RunEvent};
|
||||
|
|
@ -236,6 +236,8 @@ pub struct Projector {
|
|||
/// stream grew. A wake-up for the stream's readers, never a source of
|
||||
/// facts; a reader that lags re-reads from its cursor.
|
||||
committed: broadcast::Sender<RunId>,
|
||||
/// The GitHub deployment run origins are classified against.
|
||||
github_host: GitHubHost,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Projector {
|
||||
|
|
@ -250,7 +252,14 @@ impl Projector {
|
|||
/// passes its one pool twice.
|
||||
#[must_use]
|
||||
pub fn new(records: DbPool, views: DbPool) -> Arc<Self> {
|
||||
Self::with_github_host(records, views, GitHubHost::default())
|
||||
}
|
||||
|
||||
/// [`Projector::new`] classifying run origins against `github_host`.
|
||||
#[must_use]
|
||||
pub fn with_github_host(records: DbPool, views: DbPool, github_host: GitHubHost) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
github_host,
|
||||
store: SqliteRunStore::new(records.clone()),
|
||||
platform: PlatformRecordStore::new(views.clone()),
|
||||
records,
|
||||
|
|
@ -725,9 +734,14 @@ impl Projector {
|
|||
.map_err(ProjectError::Database)?;
|
||||
}
|
||||
if let Some(projection) = view.projection.as_ref() {
|
||||
RunSummaryStore::write_petri_run_row_on_connection(&mut tx, &run_id, projection)
|
||||
.await
|
||||
.map_err(ProjectError::Store)?;
|
||||
RunSummaryStore::write_petri_run_row_on_connection(
|
||||
&mut tx,
|
||||
&self.github_host,
|
||||
&run_id,
|
||||
projection,
|
||||
)
|
||||
.await
|
||||
.map_err(ProjectError::Store)?;
|
||||
}
|
||||
tx.commit().await.map_err(ProjectError::Database)?;
|
||||
Ok(true)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use std::fmt;
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_types::settings::run::{RunCloneSettings, RunMode, RunNamespace};
|
||||
use fabro_types::{GitRunTarget, RunTarget};
|
||||
use fabro_types::{GitHubHost, GitRunTarget, RunTarget};
|
||||
|
||||
/// The revision a run starts from, in the order the target names it: an
|
||||
/// exact commit wins over a tag, and a tag over the branch head.
|
||||
|
|
@ -170,6 +170,7 @@ impl RunSource {
|
|||
/// repository checks nothing out.
|
||||
#[must_use]
|
||||
pub fn for_run(
|
||||
host: &GitHubHost,
|
||||
target: Option<&RunTarget>,
|
||||
settings: &RunNamespace,
|
||||
credentials: Option<Arc<dyn SourceCredentials>>,
|
||||
|
|
@ -180,8 +181,8 @@ impl RunSource {
|
|||
if settings.execution.mode == RunMode::DryRun {
|
||||
return None;
|
||||
}
|
||||
let validated = target.clone().validate().ok()?;
|
||||
let origin = validated.repository().https_url();
|
||||
let validated = target.clone().validate(host).ok()?;
|
||||
let origin = validated.repository().https_url(host);
|
||||
Self::for_target(validated.target(), origin, &settings.clone, credentials)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ use std::time::Duration;
|
|||
|
||||
use async_trait::async_trait;
|
||||
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken};
|
||||
use fabro_github::{GITHUB_CREDENTIAL_HELPER_KEY, GitHubCredentials, GitHubRepositoryAccess};
|
||||
use fabro_github::{GitHubCredentials, GitHubRepositoryAccess};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::run::{RunIntegrationsGithubSettings, RunMode};
|
||||
use fabro_types::{GitHubRepositorySlug, RunSpec, RunTarget};
|
||||
use fabro_types::{GitHubHost, GitHubRepositorySlug, RunSpec, RunTarget};
|
||||
use fabro_util::shell;
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
use petri_runtime::executor::{
|
||||
|
|
@ -38,6 +38,7 @@ pub struct StageCredentials {
|
|||
git_tokens: Arc<InstallationTokenSource>,
|
||||
api_tokens: Option<Arc<InstallationTokenSource>>,
|
||||
repositories: Vec<GitHubRepositorySlug>,
|
||||
host: GitHubHost,
|
||||
}
|
||||
|
||||
impl StageCredentials {
|
||||
|
|
@ -48,6 +49,7 @@ impl StageCredentials {
|
|||
pub fn for_run(
|
||||
spec: &RunSpec,
|
||||
credentials: Option<&GitHubCredentials>,
|
||||
host: &GitHubHost,
|
||||
read_tokens: Option<Arc<InstallationTokenSource>>,
|
||||
) -> anyhow::Result<Option<Self>> {
|
||||
if spec.settings.run.execution.mode == RunMode::DryRun {
|
||||
|
|
@ -56,7 +58,7 @@ impl StageCredentials {
|
|||
let Some(RunTarget::Git(target)) = &spec.target else {
|
||||
return Ok(None);
|
||||
};
|
||||
let repository = target.clone().validate()?.repository().clone();
|
||||
let repository = target.clone().validate(host)?.repository().clone();
|
||||
let integration = spec
|
||||
.settings
|
||||
.run
|
||||
|
|
@ -64,7 +66,8 @@ impl StageCredentials {
|
|||
.github
|
||||
.resolve_integration()?;
|
||||
let Some(access) = GitHubRepositoryAccess::new(
|
||||
Some(&repository.https_url()),
|
||||
host,
|
||||
Some(&repository.https_url(host)),
|
||||
&integration.additional_repositories,
|
||||
integration.permissions.clone(),
|
||||
)?
|
||||
|
|
@ -76,7 +79,7 @@ impl StageCredentials {
|
|||
};
|
||||
let api_tokens = integration
|
||||
.is_token_requested()
|
||||
.then(|| InstallationTokenSource::for_access(credentials, &access))
|
||||
.then(|| InstallationTokenSource::for_access(credentials, host, &access))
|
||||
.transpose()?;
|
||||
let contents_declared = integration
|
||||
.permissions
|
||||
|
|
@ -96,6 +99,7 @@ impl StageCredentials {
|
|||
git_tokens,
|
||||
api_tokens,
|
||||
repositories,
|
||||
host: host.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -261,14 +265,15 @@ impl CredentialEnv {
|
|||
}
|
||||
let password = utf8_percent_encode(token.token.expose(), NON_ALPHANUMERIC).to_string();
|
||||
self.masker.register_explicit(&password);
|
||||
let authority = self.credentials.host.host();
|
||||
let store = self
|
||||
.credentials
|
||||
.repositories
|
||||
.iter()
|
||||
.flat_map(|repo| {
|
||||
[
|
||||
format!("https://x-access-token:{password}@github.com/{repo}\n"),
|
||||
format!("https://x-access-token:{password}@github.com/{repo}.git\n"),
|
||||
format!("https://x-access-token:{password}@{authority}/{repo}\n"),
|
||||
format!("https://x-access-token:{password}@{authority}/{repo}.git\n"),
|
||||
]
|
||||
})
|
||||
.collect::<String>();
|
||||
|
|
@ -307,24 +312,18 @@ impl CredentialEnv {
|
|||
})?;
|
||||
// Preserve fetch/publish headers and workflow configuration. The store
|
||||
// returns a credential only for an allowed repository path.
|
||||
let host = &self.credentials.host;
|
||||
let helper_key = host.credential_helper_key();
|
||||
let rewrite_key = format!("url.{}/.insteadOf", host.web_url());
|
||||
let entries = [
|
||||
(
|
||||
"credential.https://github.com.useHttpPath",
|
||||
format!("credential.{}.useHttpPath", host.web_url()),
|
||||
"true".to_string(),
|
||||
),
|
||||
(GITHUB_CREDENTIAL_HELPER_KEY, String::new()),
|
||||
(
|
||||
GITHUB_CREDENTIAL_HELPER_KEY,
|
||||
format!("store --file={}", self.store_path()),
|
||||
),
|
||||
(
|
||||
"url.https://github.com/.insteadOf",
|
||||
"git@github.com:".to_string(),
|
||||
),
|
||||
(
|
||||
"url.https://github.com/.insteadOf",
|
||||
"ssh://git@github.com/".to_string(),
|
||||
),
|
||||
(helper_key.clone(), String::new()),
|
||||
(helper_key, format!("store --file={}", self.store_path())),
|
||||
(rewrite_key.clone(), format!("git@{}:", host.hostname())),
|
||||
(rewrite_key, format!("ssh://git@{}/", host.hostname())),
|
||||
];
|
||||
for index in 0..count {
|
||||
for prefix in ["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"] {
|
||||
|
|
@ -339,7 +338,7 @@ impl CredentialEnv {
|
|||
for (offset, (key, value)) in entries.iter().enumerate() {
|
||||
env.insert(
|
||||
format!("GIT_CONFIG_KEY_{}", count + offset).into(),
|
||||
(*key).into(),
|
||||
key.as_str().into(),
|
||||
);
|
||||
env.insert(
|
||||
format!("GIT_CONFIG_VALUE_{}", count + offset).into(),
|
||||
|
|
@ -369,6 +368,18 @@ impl SpawnEnv for CredentialEnv {
|
|||
if let Some(tokens) = &self.credentials.api_tokens {
|
||||
let resolved = self.resolve(tokens, "resolve").await?;
|
||||
env.insert(EnvVars::GITHUB_TOKEN.into(), resolved.token.expose().into());
|
||||
// The `gh` CLI reads GITHUB_TOKEN for github.com only; any other
|
||||
// host needs GH_HOST and GH_ENTERPRISE_TOKEN.
|
||||
if !self.credentials.host.is_github_com() {
|
||||
env.insert(
|
||||
EnvVars::GH_HOST.into(),
|
||||
self.credentials.host.hostname().into(),
|
||||
);
|
||||
env.insert(
|
||||
EnvVars::GH_ENTERPRISE_TOKEN.into(),
|
||||
resolved.token.expose().into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The store and the helper configuration that names it live in the
|
||||
// scope's sandbox; a one-shot container cannot read either.
|
||||
|
|
@ -465,17 +476,150 @@ mod tests {
|
|||
}
|
||||
|
||||
fn credentials(api: bool) -> StageCredentials {
|
||||
credentials_on(api, GitHubHost::default())
|
||||
}
|
||||
|
||||
fn credentials_on(api: bool, host: GitHubHost) -> StageCredentials {
|
||||
let tokens = test_support::installation_token_source(
|
||||
"acme/private",
|
||||
Arc::new(RotatingMinter(AtomicUsize::new(0))),
|
||||
);
|
||||
StageCredentials {
|
||||
git_tokens: tokens.clone(),
|
||||
api_tokens: api.then_some(tokens),
|
||||
git_tokens: tokens.clone(),
|
||||
api_tokens: api.then_some(tokens),
|
||||
repositories: vec![GitHubRepositorySlug::try_new("acme/private").expect("slug")],
|
||||
host,
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "sync read of a test fixture's credential store; not on a Tokio path"
|
||||
)]
|
||||
/// The environment a process spawn in a scope receives under `host`, and
|
||||
/// the credential store written for it.
|
||||
async fn process_env_on(host: GitHubHost) -> (BTreeMap<SmolStr, SmolStr>, String) {
|
||||
let dir = tempfile::tempdir().expect("directory");
|
||||
let runtime = providers::standard_runtime(&SandboxProviderConfig::default());
|
||||
let router = runtime.sandbox_router_for(dir.path()).expect("router");
|
||||
let handle = router
|
||||
.acquire(
|
||||
&ScopeSpec::new(ScopeId::new(0), "host-credentials"),
|
||||
&AcquireContext::bare(),
|
||||
)
|
||||
.await
|
||||
.expect("acquire");
|
||||
let store = dir.path().join("store-directory");
|
||||
fs::create_dir(&store).expect("store directory");
|
||||
let env = CredentialEnv {
|
||||
inner: handle.exec(),
|
||||
credentials: credentials_on(true, host),
|
||||
masker: MapSecrets::empty().masker(),
|
||||
directory: store.display().to_string(),
|
||||
written: Mutex::new(None),
|
||||
};
|
||||
let mut process = BTreeMap::new();
|
||||
env.apply(SpawnTarget::Process, &mut process)
|
||||
.await
|
||||
.expect("apply");
|
||||
let written = fs::read_to_string(store.join("store")).expect("store");
|
||||
assert!(
|
||||
router
|
||||
.release(handle, ScopeOutcome::Succeeded)
|
||||
.await
|
||||
.is_clean()
|
||||
);
|
||||
(process, written)
|
||||
}
|
||||
|
||||
/// The git config entries of `env`, in order, as `(key, value)`.
|
||||
fn git_config(env: &BTreeMap<SmolStr, SmolStr>) -> Vec<(String, String)> {
|
||||
let count: usize = env["GIT_CONFIG_COUNT"].parse().expect("count");
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
(
|
||||
env[format!("GIT_CONFIG_KEY_{index}").as_str()].to_string(),
|
||||
env[format!("GIT_CONFIG_VALUE_{index}").as_str()].to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_com_scopes_keep_the_default_git_config_and_no_gh_host() {
|
||||
let (env, store) = process_env_on(GitHubHost::default()).await;
|
||||
let config = git_config(&env);
|
||||
let keys: Vec<_> = config.iter().map(|(key, _)| key.as_str()).collect();
|
||||
assert_eq!(keys, [
|
||||
"credential.https://github.com.useHttpPath",
|
||||
"credential.https://github.com.helper",
|
||||
"credential.https://github.com.helper",
|
||||
"url.https://github.com/.insteadOf",
|
||||
"url.https://github.com/.insteadOf",
|
||||
]);
|
||||
assert_eq!(config[3].1, "git@github.com:");
|
||||
assert_eq!(config[4].1, "ssh://git@github.com/");
|
||||
assert!(store.contains("@github.com/acme/private\n"));
|
||||
assert!(store.contains("@github.com/acme/private.git\n"));
|
||||
assert!(!env.contains_key("GH_HOST"));
|
||||
assert!(!env.contains_key("GH_ENTERPRISE_TOKEN"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_ghes_scope_scopes_git_and_gh_credentials_to_its_host() {
|
||||
let host = GitHubHost::new("https://ghe.example.com", None).expect("host");
|
||||
let (env, store) = process_env_on(host).await;
|
||||
let config = git_config(&env);
|
||||
assert_eq!(
|
||||
config[0],
|
||||
(
|
||||
"credential.https://ghe.example.com.useHttpPath".to_string(),
|
||||
"true".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
config[1],
|
||||
(
|
||||
"credential.https://ghe.example.com.helper".to_string(),
|
||||
String::new()
|
||||
)
|
||||
);
|
||||
assert_eq!(config[2].0, "credential.https://ghe.example.com.helper");
|
||||
assert!(config[2].1.starts_with("store --file="));
|
||||
assert_eq!(
|
||||
config[3],
|
||||
(
|
||||
"url.https://ghe.example.com/.insteadOf".to_string(),
|
||||
"git@ghe.example.com:".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
config[4],
|
||||
(
|
||||
"url.https://ghe.example.com/.insteadOf".to_string(),
|
||||
"ssh://git@ghe.example.com/".to_string()
|
||||
)
|
||||
);
|
||||
assert!(config.iter().all(|(key, _)| !key.contains("github.com")));
|
||||
assert!(store.contains("@ghe.example.com/acme/private\n"));
|
||||
assert!(store.contains("@ghe.example.com/acme/private.git\n"));
|
||||
assert!(!store.contains("github.com"));
|
||||
assert_eq!(env["GH_HOST"], "ghe.example.com");
|
||||
assert_eq!(env["GH_ENTERPRISE_TOKEN"], env["GITHUB_TOKEN"]);
|
||||
assert!(env["GITHUB_TOKEN"].starts_with("scripted-token-generation-"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_ghes_scope_with_a_port_keeps_the_port_out_of_ssh_and_gh_host() {
|
||||
let host = GitHubHost::new("https://ghe.example.com:8443", None).expect("host");
|
||||
let (env, _store) = process_env_on(host).await;
|
||||
let config = git_config(&env);
|
||||
assert_eq!(config[3].0, "url.https://ghe.example.com:8443/.insteadOf");
|
||||
assert_eq!(config[3].1, "git@ghe.example.com:");
|
||||
assert_eq!(config[4].1, "ssh://git@ghe.example.com/");
|
||||
assert_eq!(env["GH_HOST"], "ghe.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_origin_access_does_not_request_an_api_token() {
|
||||
let mut spec = types_support::test_run_spec();
|
||||
|
|
@ -487,9 +631,14 @@ mod tests {
|
|||
);
|
||||
let pat = GitHubCredentials::Pat("scripted-personal-access-token".to_string());
|
||||
let read = InstallationTokenSource::pat("scripted-personal-access-token".to_string());
|
||||
let default = StageCredentials::for_run(&spec, Some(&pat), Some(Arc::clone(&read)))
|
||||
.expect("policy")
|
||||
.expect("credentials");
|
||||
let default = StageCredentials::for_run(
|
||||
&spec,
|
||||
Some(&pat),
|
||||
&GitHubHost::default(),
|
||||
Some(Arc::clone(&read)),
|
||||
)
|
||||
.expect("policy")
|
||||
.expect("credentials");
|
||||
assert!(default.api_tokens.is_none());
|
||||
assert_eq!(default.repositories.len(), 1);
|
||||
spec.settings
|
||||
|
|
@ -504,9 +653,10 @@ mod tests {
|
|||
.github
|
||||
.additional_repositories
|
||||
.insert(GitHubRepositorySlug::try_new("acme/another").expect("slug"));
|
||||
let declared = StageCredentials::for_run(&spec, Some(&pat), Some(read))
|
||||
.expect("policy")
|
||||
.expect("credentials");
|
||||
let declared =
|
||||
StageCredentials::for_run(&spec, Some(&pat), &GitHubHost::default(), Some(read))
|
||||
.expect("policy")
|
||||
.expect("credentials");
|
||||
assert!(declared.api_tokens.is_some());
|
||||
assert_eq!(declared.repositories.len(), 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_types::{
|
||||
ArtifactSource, BlobHash, DiffSummary, GitIdentity, PairId, PairTarget, Principal,
|
||||
ArtifactSource, BlobHash, DiffSummary, GitHubHost, GitIdentity, PairId, PairTarget, Principal,
|
||||
PullRequestCreationId, PullRequestLink, RunControlAction, RunId, RunNoticeLevel, RunSpec,
|
||||
RunStatus,
|
||||
};
|
||||
|
|
@ -557,27 +557,37 @@ pub struct PullRequestFailedRecord {
|
|||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PullRequestLinkedRecord {
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub number: u64,
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub number: u64,
|
||||
/// The pull request's URL when it is not on github.com; github.com links
|
||||
/// are derived, so their records are unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub html_url: Option<String>,
|
||||
}
|
||||
|
||||
impl PullRequestLinkedRecord {
|
||||
#[must_use]
|
||||
pub fn link(&self) -> PullRequestLink {
|
||||
PullRequestLink {
|
||||
owner: self.owner.clone(),
|
||||
repo: self.repo.clone(),
|
||||
number: self.number,
|
||||
owner: self.owner.clone(),
|
||||
repo: self.repo.clone(),
|
||||
number: self.number,
|
||||
html_url: self.html_url.clone().unwrap_or_else(|| {
|
||||
GitHubHost::default().pull_request_url(&self.owner, &self.repo, self.number)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_link(link: &PullRequestLink) -> Self {
|
||||
Self {
|
||||
owner: link.owner.clone(),
|
||||
repo: link.repo.clone(),
|
||||
number: link.number,
|
||||
owner: link.owner.clone(),
|
||||
repo: link.repo.clone(),
|
||||
number: link.number,
|
||||
html_url: (link.html_url
|
||||
!= GitHubHost::default().pull_request_url(&link.owner, &link.repo, link.number))
|
||||
.then(|| link.html_url.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -941,16 +951,18 @@ mod tests {
|
|||
}
|
||||
PlatformRecordKind::PullRequestLinked => {
|
||||
PlatformRecord::PullRequestLinked(PullRequestLinkedRecord {
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 7,
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 7,
|
||||
html_url: None,
|
||||
})
|
||||
}
|
||||
PlatformRecordKind::PullRequestUnlinked => {
|
||||
PlatformRecord::PullRequestUnlinked(PullRequestLinkedRecord {
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 7,
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 7,
|
||||
html_url: None,
|
||||
})
|
||||
}
|
||||
PlatformRecordKind::NotificationSent => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! the board and the scheduler read, derived from the projection.
|
||||
|
||||
use fabro_types::{
|
||||
AskFabro, RepositoryRef, Run, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,
|
||||
AskFabro, GitHubHost, RepositoryRef, Run, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,
|
||||
RunProjection, RunSize, RunTimestamps, WorkflowRef, sum_usage,
|
||||
};
|
||||
use lithos_llm::types::Usage;
|
||||
|
|
@ -10,7 +10,7 @@ use lithos_llm::types::Usage;
|
|||
/// The run summary (`Run`) a projection stands for: what the run list, the
|
||||
/// board and the scheduler read.
|
||||
#[must_use]
|
||||
pub fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
||||
pub fn build_summary(host: &GitHubHost, state: &RunProjection, run_id: &RunId) -> Run {
|
||||
let goal = state.spec.graph.goal().to_string();
|
||||
let diff_summary = state
|
||||
.conclusion
|
||||
|
|
@ -65,6 +65,7 @@ pub fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
},
|
||||
automation: state.spec.automation.clone(),
|
||||
repository: Some(RepositoryRef::from_origin_and_source(
|
||||
host,
|
||||
repo_origin_url,
|
||||
source_directory.as_deref(),
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::fmt::Write as _;
|
|||
use std::sync::{Arc, LazyLock, PoisonError, RwLock};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::{Run, RunId, RunSize, RunStatusKind, RunTiming, timing};
|
||||
use fabro_types::{GitHubHost, Run, RunId, RunSize, RunStatusKind, RunTiming, timing};
|
||||
use sqlx::query::Query;
|
||||
use sqlx::sqlite::{SqliteArguments, SqliteConnection, SqliteRow};
|
||||
use sqlx::{QueryBuilder, Row as _, Sqlite, SqlitePool};
|
||||
|
|
@ -202,13 +202,15 @@ impl RunSummaryStore {
|
|||
|
||||
/// Write the `runs` row of a run from its projection, on a connection
|
||||
/// the caller holds a transaction on: the columns the list views and
|
||||
/// the scheduler read, and the summary JSON.
|
||||
/// the scheduler read, and the summary JSON. `host` is the GitHub
|
||||
/// deployment the run's origin is classified against.
|
||||
pub async fn write_petri_run_row_on_connection(
|
||||
connection: &mut SqliteConnection,
|
||||
host: &GitHubHost,
|
||||
run_id: &RunId,
|
||||
projection: &RunProjection,
|
||||
) -> Result<()> {
|
||||
let record = PreparedRunSummary::from_projection(run_id, projection);
|
||||
let record = PreparedRunSummary::from_projection(host, run_id, projection);
|
||||
bind_run_columns(
|
||||
sqlx::query(UPSERT_PETRI_RUN_SQL).bind(run_id.to_string()),
|
||||
&record,
|
||||
|
|
@ -408,8 +410,8 @@ struct PreparedRunSummary {
|
|||
}
|
||||
|
||||
impl PreparedRunSummary {
|
||||
fn from_projection(run_id: &RunId, projection: &RunProjection) -> Self {
|
||||
let mut run = build_summary(projection, run_id);
|
||||
fn from_projection(host: &GitHubHost, run_id: &RunId, projection: &RunProjection) -> Self {
|
||||
let mut run = build_summary(host, projection, run_id);
|
||||
if run.timing.is_none() {
|
||||
let at = run
|
||||
.timestamps
|
||||
|
|
@ -706,6 +708,7 @@ mod tests {
|
|||
let mut transaction = store.pool.begin().await.unwrap();
|
||||
RunSummaryStore::write_petri_run_row_on_connection(
|
||||
&mut transaction,
|
||||
&fabro_types::GitHubHost::default(),
|
||||
&projection.spec.run_id,
|
||||
projection,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ impl FabroRunCreateParams {
|
|||
if let Some(target) = &spec.target {
|
||||
target
|
||||
.clone()
|
||||
.validate()
|
||||
.canonical()
|
||||
.map_err(|error| ToolError::from_anyhow(&error.into()))?;
|
||||
}
|
||||
for (key, value) in &spec.args.inputs {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use anyhow::{Context as _, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use fabro_github::{
|
||||
GitHubCredentials, create_installation_access_token_for_projects, sign_app_jwt,
|
||||
GitHubCredentials, GitHubHost, create_installation_access_token_for_projects, sign_app_jwt,
|
||||
};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ pub struct GitHubTracker {
|
|||
owner: String,
|
||||
repo: String,
|
||||
project_number: u64,
|
||||
base_url: String,
|
||||
host: GitHubHost,
|
||||
project_node_id: OnceCell<String>,
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ impl GitHubTracker {
|
|||
owner: String,
|
||||
repo: String,
|
||||
project_number: u64,
|
||||
base_url: String,
|
||||
host: GitHubHost,
|
||||
) -> Self {
|
||||
Self {
|
||||
creds,
|
||||
|
|
@ -54,13 +54,13 @@ impl GitHubTracker {
|
|||
owner,
|
||||
repo,
|
||||
project_number,
|
||||
base_url,
|
||||
host,
|
||||
project_node_id: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn graphql_url(&self) -> String {
|
||||
format!("{}/graphql", self.base_url)
|
||||
self.host.graphql_url()
|
||||
}
|
||||
|
||||
async fn fresh_token(&self) -> anyhow::Result<String> {
|
||||
|
|
@ -73,7 +73,7 @@ impl GitHubTracker {
|
|||
&jwt,
|
||||
&self.owner,
|
||||
&self.repo,
|
||||
&self.base_url,
|
||||
&self.host,
|
||||
)
|
||||
.await
|
||||
.context("creating GitHub App installation token")
|
||||
|
|
@ -645,7 +645,7 @@ mod tests {
|
|||
"owner".to_string(),
|
||||
"repo".to_string(),
|
||||
1,
|
||||
server_url.to_string(),
|
||||
GitHubHost::new(server_url, Some(server_url)).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1225,7 +1225,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn github_tracker_fetch_issues_by_ids_empty() {
|
||||
let pem = test_rsa_key();
|
||||
let tracker = mock_github_tracker("http://unused", pem);
|
||||
let tracker = mock_github_tracker("http://127.0.0.1:1", pem);
|
||||
|
||||
// Empty input → no HTTP calls at all
|
||||
let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap();
|
||||
|
|
|
|||
|
|
@ -465,9 +465,13 @@ async fn reconcile_existing_pull_request(
|
|||
.await;
|
||||
Ok(Some(CreatedPullRequest {
|
||||
link: PullRequestLink {
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
number: existing.number,
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
number: existing.number,
|
||||
html_url: req
|
||||
.github
|
||||
.host()
|
||||
.pull_request_url(owner, repo, existing.number),
|
||||
},
|
||||
title: existing.title,
|
||||
base_branch: req.base_branch.to_string(),
|
||||
|
|
@ -553,8 +557,8 @@ pub async fn open_pull_request(
|
|||
req: OpenPullRequestRequest<'_>,
|
||||
) -> Result<CreatedPullRequest, String> {
|
||||
let https_url = ssh_url_to_https(req.origin_url);
|
||||
let (owner, repo) =
|
||||
github_app::parse_github_owner_repo(&https_url).map_err(|err| format!("{err:#}"))?;
|
||||
let (owner, repo) = github_app::parse_github_owner_repo(req.github.host(), &https_url)
|
||||
.map_err(|err| format!("{err:#}"))?;
|
||||
|
||||
// Verify before generating content: this is the cheap check, and a stale
|
||||
// branch would otherwise cost a full LLM call before failing.
|
||||
|
|
@ -621,10 +625,15 @@ pub async fn open_pull_request(
|
|||
)
|
||||
.await;
|
||||
|
||||
let html_url = req
|
||||
.github
|
||||
.host()
|
||||
.pull_request_url(&owner, &repo, created.number);
|
||||
let link = PullRequestLink {
|
||||
owner,
|
||||
repo,
|
||||
number: created.number,
|
||||
html_url,
|
||||
};
|
||||
|
||||
Ok(CreatedPullRequest {
|
||||
|
|
@ -1272,9 +1281,11 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
let payload = pr_content_json("Fix bug", "Narrative.");
|
||||
let harness = setup_fallback_test_harness_with_branch_sha(&payload, "stale-sha").await;
|
||||
let github_base_url = harness.github_server.url("");
|
||||
let github_host =
|
||||
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
|
||||
let error = open_pull_request(OpenPullRequestRequest {
|
||||
run_id: RunId::new(),
|
||||
github: fabro_github::GitHubContext::new(&harness.creds, &github_base_url),
|
||||
github: fabro_github::GitHubContext::new(&harness.creds, &github_host),
|
||||
origin_url: "https://github.com/owner/repo.git",
|
||||
base_branch: "main",
|
||||
head_branch: "fabro/run/123",
|
||||
|
|
@ -1543,7 +1554,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
&payload,
|
||||
"final-sha",
|
||||
serde_json::json!([{
|
||||
"html_url": "https://github.com/owner/repo/pull/7",
|
||||
"html_url": "https://github.com/OWNER/Repo/pull/7",
|
||||
"number": 7,
|
||||
"node_id": "PR_existing",
|
||||
"title": "Reconciled title",
|
||||
|
|
@ -1553,7 +1564,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
.await;
|
||||
|
||||
let github_base_url = harness.github_server.url("");
|
||||
let github = github_app::GitHubContext::new(&harness.creds, &github_base_url);
|
||||
let github_host =
|
||||
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
|
||||
let github = github_app::GitHubContext::new(&harness.creds, &github_host);
|
||||
|
||||
let result = open_pull_request(OpenPullRequestRequest {
|
||||
run_id: RunId::new(),
|
||||
|
|
@ -1576,6 +1589,8 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
.expect("reconciliation should adopt the existing pull request");
|
||||
|
||||
assert_eq!(result.link.number, 7);
|
||||
// The URL follows the stored coordinates, not the API's spelling.
|
||||
assert_eq!(result.link.html_url, "https://github.com/owner/repo/pull/7");
|
||||
assert_eq!(result.title, "Reconciled title");
|
||||
// Adoption must not cost an LLM call or a create request.
|
||||
assert_eq!(
|
||||
|
|
@ -1601,7 +1616,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
let harness = setup_fallback_test_harness(&payload).await;
|
||||
|
||||
let github_base_url = harness.github_server.url("");
|
||||
let github = github_app::GitHubContext::new(&harness.creds, &github_base_url);
|
||||
let github_host =
|
||||
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
|
||||
let github = github_app::GitHubContext::new(&harness.creds, &github_host);
|
||||
|
||||
let result = open_pull_request(OpenPullRequestRequest {
|
||||
run_id: RunId::new(),
|
||||
|
|
@ -1624,6 +1641,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
.expect("PR creation should succeed");
|
||||
|
||||
assert_eq!(result.title, "Fix telemetry leak");
|
||||
// The mock API answers with an `example.test` URL; the stored URL is
|
||||
// derived from the stored coordinates instead.
|
||||
assert_eq!(result.link.html_url, "https://github.com/owner/repo/pull/1");
|
||||
harness.assert_mocks_called_once().await;
|
||||
}
|
||||
|
||||
|
|
@ -1635,7 +1655,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
let harness = setup_fallback_test_harness(&payload).await;
|
||||
|
||||
let github_base_url = harness.github_server.url("");
|
||||
let github = github_app::GitHubContext::new(&harness.creds, &github_base_url);
|
||||
let github_host =
|
||||
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
|
||||
let github = github_app::GitHubContext::new(&harness.creds, &github_host);
|
||||
|
||||
// Single ~200-char line, no `Plan:` / heading prefix, no newlines.
|
||||
let goal = "x".repeat(200);
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@ mod tests {
|
|||
let mut transaction = summaries.pool().begin().await.unwrap();
|
||||
RunSummaryStore::write_petri_run_row_on_connection(
|
||||
&mut transaction,
|
||||
&fabro_types::GitHubHost::default(),
|
||||
&fixtures::RUN_1,
|
||||
&projection,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,9 +143,10 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
deletions: 4,
|
||||
}),
|
||||
pull_request: Some(PullRequestLink {
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 123,
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 123,
|
||||
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
|
||||
}),
|
||||
current_question: None,
|
||||
superseded_by: None,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@ use std::path::Path;
|
|||
|
||||
use fabro_types::settings::run::McpServerSettings;
|
||||
use fabro_types::settings::{RunNamespace, WorkflowNamespace};
|
||||
use fabro_types::{ServerSettings, UserSettings, WorkflowSettings};
|
||||
use fabro_types::{GitHubHost, ServerSettings, UserSettings, WorkflowSettings};
|
||||
use fabro_util::error::SharedError;
|
||||
|
||||
use crate::defaults::DEFAULTS_LAYER;
|
||||
use crate::load::load_settings_path;
|
||||
use crate::parse::{SettingsSource, validate_settings_source};
|
||||
use crate::resolve::{
|
||||
ResolveError, resolve_cli, resolve_project, resolve_run, resolve_server, resolve_workflow,
|
||||
ResolveError, resolve_cli, resolve_github_host, resolve_project, resolve_run, resolve_server,
|
||||
resolve_workflow,
|
||||
};
|
||||
use crate::user::load_settings_config;
|
||||
use crate::{
|
||||
|
|
@ -86,6 +87,20 @@ impl ServerSettingsBuilder {
|
|||
Self::from_layer(&layer)
|
||||
}
|
||||
|
||||
/// The configured GitHub host alone, for callers that have no complete
|
||||
/// server configuration to resolve (the CLI without a local server).
|
||||
pub fn load_default_github_host() -> Result<GitHubHost> {
|
||||
let layer = load_settings_config(None)?;
|
||||
let github = layer
|
||||
.server
|
||||
.and_then(|server| server.integrations)
|
||||
.and_then(|integrations| integrations.github)
|
||||
.unwrap_or_default();
|
||||
let mut errors = Vec::new();
|
||||
let host = resolve_github_host(&github, &mut errors);
|
||||
finish_result(host, "failed to resolve the GitHub host", errors)
|
||||
}
|
||||
|
||||
pub fn from_toml(source: &str) -> Result<ServerSettings> {
|
||||
let layer = parse_settings_toml(source, SettingsSource::ActiveSettings)?;
|
||||
Self::from_layer(&layer)
|
||||
|
|
|
|||
|
|
@ -233,6 +233,10 @@ pub struct GithubIntegrationLayer {
|
|||
pub slug: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webhooks: Option<IntegrationWebhooksLayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub web_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_url: Option<String>,
|
||||
}
|
||||
|
||||
/// `[server.integrations.slack]` — Slack workspace credentials and defaults.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub use error::ResolveError;
|
|||
use fabro_types::settings::InterpString;
|
||||
pub use project::resolve_project;
|
||||
pub use run::resolve_run;
|
||||
pub(crate) use server::resolve_github_host;
|
||||
pub use server::resolve_server;
|
||||
pub use workflow::resolve_workflow;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::server::{
|
||||
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
|
||||
ObjectStoreProvider, ObjectStoreSettings, SandboxPluginSettings, ServerApiSettings,
|
||||
|
|
@ -11,6 +10,7 @@ use fabro_types::settings::server::{
|
|||
ServerSchedulerSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
|
||||
WebhookStrategy,
|
||||
};
|
||||
use fabro_types::{GitHubHost, SandboxProviderKind};
|
||||
use fabro_util::Home;
|
||||
|
||||
use super::{
|
||||
|
|
@ -19,9 +19,10 @@ use super::{
|
|||
};
|
||||
use crate::user::default_storage_dir;
|
||||
use crate::{
|
||||
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
|
||||
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer,
|
||||
ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer, ServerWebLayer,
|
||||
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
|
||||
ServerApiLayer, ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
|
||||
ServerListenLayer, ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer,
|
||||
ServerWebLayer,
|
||||
};
|
||||
|
||||
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
|
||||
|
|
@ -29,7 +30,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
|
|||
let listen = resolve_listen(layer.listen.as_ref(), errors);
|
||||
let web = resolve_web(layer.web.as_ref());
|
||||
let auth = resolve_auth(layer.auth.as_ref(), errors);
|
||||
let integrations = resolve_integrations(layer.integrations.as_ref());
|
||||
let integrations = resolve_integrations(layer.integrations.as_ref(), errors);
|
||||
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
|
||||
|
||||
let api_url = layer.api.as_ref().and_then(|api| api.url.clone());
|
||||
|
|
@ -330,7 +331,10 @@ fn resolve_object_store(
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
|
||||
fn resolve_integrations(
|
||||
layer: Option<&ServerIntegrationsLayer>,
|
||||
errors: &mut Vec<ResolveError>,
|
||||
) -> ServerIntegrationsSettings {
|
||||
ServerIntegrationsSettings {
|
||||
github: layer
|
||||
.and_then(|integrations| integrations.github.as_ref())
|
||||
|
|
@ -344,13 +348,15 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
|
|||
github.client_id.as_deref(),
|
||||
);
|
||||
warn_if_demoted_template("server.integrations.github.slug", github.slug.as_deref());
|
||||
let host = resolve_github_host(github, errors);
|
||||
GithubIntegrationSettings {
|
||||
enabled: github.enabled.unwrap_or(true),
|
||||
strategy: github.strategy.unwrap_or_default(),
|
||||
app_id: github.app_id.clone(),
|
||||
host,
|
||||
enabled: github.enabled.unwrap_or(true),
|
||||
strategy: github.strategy.unwrap_or_default(),
|
||||
app_id: github.app_id.clone(),
|
||||
client_id: github.client_id.clone(),
|
||||
slug: github.slug.clone(),
|
||||
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
|
||||
slug: github.slug.clone(),
|
||||
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
|
||||
}
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
|
|
@ -375,6 +381,21 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_github_host(
|
||||
layer: &GithubIntegrationLayer,
|
||||
errors: &mut Vec<ResolveError>,
|
||||
) -> GitHubHost {
|
||||
let default = GitHubHost::default();
|
||||
let web_url = layer.web_url.as_deref().unwrap_or(default.web_url());
|
||||
GitHubHost::new(web_url, layer.api_url.as_deref()).unwrap_or_else(|err| {
|
||||
errors.push(ResolveError::Invalid {
|
||||
path: format!("server.integrations.github.{}", err.field()),
|
||||
reason: err.to_string(),
|
||||
});
|
||||
default
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_github_webhooks(layer: &IntegrationWebhooksLayer) -> IntegrationWebhooksSettings {
|
||||
IntegrationWebhooksSettings {
|
||||
strategy: layer.strategy,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
reason = "sync test fixture setup and raw template source assertions; not on a Tokio path"
|
||||
)]
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::server::{
|
||||
GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod,
|
||||
ServerListenSettings, ServerNamespace,
|
||||
};
|
||||
use fabro_types::{GitHubHost, SandboxProviderKind};
|
||||
use fabro_util::Home;
|
||||
use temp_env::with_var;
|
||||
|
||||
|
|
@ -108,6 +108,8 @@ fn resolved_server_integrations_disable_slack_when_config_is_absent() {
|
|||
"client_id": null,
|
||||
"slug": null,
|
||||
"webhooks": null,
|
||||
"web_url": "https://github.com",
|
||||
"api_url": "https://api.github.com",
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
|
|
@ -527,6 +529,72 @@ enabled = true
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_host_defaults_to_github_com() {
|
||||
let settings = resolve_server(&parse("_version = 1\n"));
|
||||
assert_eq!(settings.integrations.github.host, GitHubHost::default());
|
||||
|
||||
let settings = resolve_server(&parse(
|
||||
"_version = 1\n\n[server.integrations.github]\nenabled = true\n",
|
||||
));
|
||||
assert_eq!(settings.integrations.github.host, GitHubHost::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_host_derives_api_url_from_web_url() {
|
||||
let settings = resolve_server(&parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.integrations.github]
|
||||
web_url = "https://ghe.example.com"
|
||||
"#,
|
||||
));
|
||||
let host = &settings.integrations.github.host;
|
||||
assert_eq!(host.web_url(), "https://ghe.example.com");
|
||||
assert_eq!(host.api_url(), "https://ghe.example.com/api/v3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_host_honours_explicit_api_url() {
|
||||
let settings = resolve_server(&parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.integrations.github]
|
||||
web_url = "https://ghe.example.com"
|
||||
api_url = "https://api.ghe.example.com"
|
||||
"#,
|
||||
));
|
||||
assert_eq!(
|
||||
settings.integrations.github.host.api_url(),
|
||||
"https://api.ghe.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_host_rejects_invalid_web_url() {
|
||||
let file = parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.integrations.github]
|
||||
web_url = "http://ghe.example.com"
|
||||
"#,
|
||||
);
|
||||
let error =
|
||||
ServerSettingsBuilder::from_layer(&file).expect_err("plain http should be rejected");
|
||||
let errors = resolve_errors(error);
|
||||
assert!(
|
||||
errors.iter().any(|err| {
|
||||
err.to_string()
|
||||
.contains("server.integrations.github.web_url")
|
||||
&& err.to_string().contains("must use https")
|
||||
}),
|
||||
"unexpected errors: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_rejects_removed_server_ip_allowlist() {
|
||||
let err = r#"
|
||||
|
|
|
|||
|
|
@ -118,12 +118,15 @@ impl EnvVars {
|
|||
pub const ZAI_API_KEY: &'static str = "ZAI_API_KEY";
|
||||
|
||||
// GitHub, OAuth, and Slack
|
||||
pub const GH_ENTERPRISE_TOKEN: &'static str = "GH_ENTERPRISE_TOKEN";
|
||||
pub const GH_HOST: &'static str = "GH_HOST";
|
||||
pub const GH_TOKEN: &'static str = "GH_TOKEN";
|
||||
pub const GITHUB_APP_CLIENT_SECRET: &'static str = "GITHUB_APP_CLIENT_SECRET";
|
||||
pub const GITHUB_APP_PRIVATE_KEY: &'static str = "GITHUB_APP_PRIVATE_KEY";
|
||||
pub const GITHUB_APP_WEBHOOK_SECRET: &'static str = "GITHUB_APP_WEBHOOK_SECRET";
|
||||
pub const GITHUB_BASE_URL: &'static str = "GITHUB_BASE_URL";
|
||||
pub const GITHUB_TOKEN: &'static str = "GITHUB_TOKEN";
|
||||
pub const GITHUB_WEB_URL: &'static str = "GITHUB_WEB_URL";
|
||||
pub const OAUTH_CALLBACK_PATH: &'static str = "OAUTH_CALLBACK_PATH";
|
||||
pub const OAUTH_CLIENT_ID: &'static str = "OAUTH_CLIENT_ID";
|
||||
pub const OAUTH_ISSUER: &'static str = "OAUTH_ISSUER";
|
||||
|
|
@ -185,6 +188,8 @@ impl EnvVars {
|
|||
pub const RUST_BACKTRACE: &'static str = "RUST_BACKTRACE";
|
||||
pub const RUST_LOG: &'static str = "RUST_LOG";
|
||||
pub const SHELL: &'static str = "SHELL";
|
||||
pub const SSL_CERT_DIR: &'static str = "SSL_CERT_DIR";
|
||||
pub const SSL_CERT_FILE: &'static str = "SSL_CERT_FILE";
|
||||
pub const TERM: &'static str = "TERM";
|
||||
pub const TMPDIR: &'static str = "TMPDIR";
|
||||
pub const TWIN_OPENAI_BIND_ADDR: &'static str = "TWIN_OPENAI_BIND_ADDR";
|
||||
|
|
@ -279,12 +284,15 @@ mod tests {
|
|||
EnvVars::OPENROUTER_API_KEY,
|
||||
EnvVars::POOLSIDE_API_KEY,
|
||||
EnvVars::ZAI_API_KEY,
|
||||
EnvVars::GH_ENTERPRISE_TOKEN,
|
||||
EnvVars::GH_HOST,
|
||||
EnvVars::GH_TOKEN,
|
||||
EnvVars::GITHUB_APP_CLIENT_SECRET,
|
||||
EnvVars::GITHUB_APP_PRIVATE_KEY,
|
||||
EnvVars::GITHUB_APP_WEBHOOK_SECRET,
|
||||
EnvVars::GITHUB_BASE_URL,
|
||||
EnvVars::GITHUB_TOKEN,
|
||||
EnvVars::GITHUB_WEB_URL,
|
||||
EnvVars::OAUTH_CALLBACK_PATH,
|
||||
EnvVars::OAUTH_CLIENT_ID,
|
||||
EnvVars::OAUTH_ISSUER,
|
||||
|
|
@ -339,6 +347,8 @@ mod tests {
|
|||
EnvVars::RUST_BACKTRACE,
|
||||
EnvVars::RUST_LOG,
|
||||
EnvVars::SHELL,
|
||||
EnvVars::SSL_CERT_DIR,
|
||||
EnvVars::SSL_CERT_FILE,
|
||||
EnvVars::TERM,
|
||||
EnvVars::TMPDIR,
|
||||
EnvVars::TWIN_OPENAI_BIND_ADDR,
|
||||
|
|
|
|||
|
|
@ -121,9 +121,15 @@ mod tests {
|
|||
#[test]
|
||||
fn leaves_legacy_aliases_and_non_secret_config_unclassified() {
|
||||
for name in [
|
||||
// A sandbox-injected copy of the resolved token, not a vault secret.
|
||||
EnvVars::GH_ENTERPRISE_TOKEN,
|
||||
EnvVars::GH_HOST,
|
||||
EnvVars::GH_TOKEN,
|
||||
EnvVars::GITHUB_BASE_URL,
|
||||
EnvVars::GITHUB_WEB_URL,
|
||||
EnvVars::SLACK_BASE_URL,
|
||||
EnvVars::SSL_CERT_DIR,
|
||||
EnvVars::SSL_CERT_FILE,
|
||||
EnvVars::DAYTONA_API_URL,
|
||||
EnvVars::DAYTONA_ORGANIZATION_ID,
|
||||
EnvVars::DAYTONA_SERVER_URL,
|
||||
|
|
|
|||
457
lib/foundation/fabro-types/src/github_host.rs
Normal file
457
lib/foundation/fabro-types/src/github_host.rs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_WEB_URL: &str = "https://github.com";
|
||||
const DEFAULT_API_URL: &str = "https://api.github.com";
|
||||
|
||||
/// Rejected GitHub host setting, naming the offending field.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{field} {problem}")]
|
||||
pub struct GitHubHostError {
|
||||
field: &'static str,
|
||||
problem: String,
|
||||
}
|
||||
|
||||
impl GitHubHostError {
|
||||
/// Name of the rejected setting: `web_url` or `api_url`.
|
||||
#[must_use]
|
||||
pub fn field(&self) -> &'static str {
|
||||
self.field
|
||||
}
|
||||
|
||||
fn new(field: &'static str, problem: impl Into<String>) -> Self {
|
||||
Self {
|
||||
field,
|
||||
problem: problem.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The GitHub deployment Fabro talks to: github.com (the default), a GitHub
|
||||
/// Enterprise Cloud tenant with data residency (`*.ghe.com`) or a GitHub
|
||||
/// Enterprise Server instance. The web origin is the one source of truth;
|
||||
/// every other URL is derived from it unless the API URL is given explicitly.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "GitHubHostWire")]
|
||||
pub struct GitHubHost {
|
||||
web_url: String,
|
||||
api_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubHostWire {
|
||||
#[serde(default)]
|
||||
web_url: Option<String>,
|
||||
#[serde(default)]
|
||||
api_url: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<GitHubHostWire> for GitHubHost {
|
||||
type Error = GitHubHostError;
|
||||
|
||||
fn try_from(wire: GitHubHostWire) -> Result<Self, Self::Error> {
|
||||
Self::new(
|
||||
wire.web_url.as_deref().unwrap_or(DEFAULT_WEB_URL),
|
||||
wire.api_url.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GitHubHost {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
web_url: DEFAULT_WEB_URL.to_string(),
|
||||
api_url: DEFAULT_API_URL.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "GitHub host settings are public service origins; validation errors name the field and rule but never echo the value, which may embed credentials."
|
||||
)]
|
||||
fn parse_origin(
|
||||
field: &'static str,
|
||||
raw: &str,
|
||||
plain_http_anywhere: bool,
|
||||
) -> Result<url::Url, GitHubHostError> {
|
||||
let raw = raw.trim().trim_end_matches('/');
|
||||
let parsed = url::Url::parse(raw).map_err(|_| {
|
||||
GitHubHostError::new(field, "must be an https URL such as https://github.com")
|
||||
})?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| GitHubHostError::new(field, "must include a host"))?;
|
||||
let loopback = matches!(host, "localhost" | "127.0.0.1" | "[::1]");
|
||||
match parsed.scheme() {
|
||||
"https" => {}
|
||||
"http" if loopback || plain_http_anywhere => {}
|
||||
_ => {
|
||||
let problem = if plain_http_anywhere {
|
||||
"must use http or https"
|
||||
} else {
|
||||
"must use https; http is only allowed for localhost"
|
||||
};
|
||||
return Err(GitHubHostError::new(field, problem));
|
||||
}
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(GitHubHostError::new(field, "must not contain credentials"));
|
||||
}
|
||||
if parsed.query().is_some() || parsed.fragment().is_some() {
|
||||
return Err(GitHubHostError::new(
|
||||
field,
|
||||
"must not contain a query or fragment",
|
||||
));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
impl GitHubHost {
|
||||
/// Builds a host from the web origin and an optional explicit API URL.
|
||||
/// Without `api_url`, the API URL is derived from the web origin.
|
||||
pub fn new(web_url: &str, api_url: Option<&str>) -> Result<Self, GitHubHostError> {
|
||||
let web = parse_origin("web_url", web_url, false)?;
|
||||
if !matches!(web.path(), "" | "/") {
|
||||
return Err(GitHubHostError::new(
|
||||
"web_url",
|
||||
"must not contain a path; GitHub is never served under a path prefix",
|
||||
));
|
||||
}
|
||||
let web_origin = web.origin().ascii_serialization();
|
||||
let api_url = match api_url {
|
||||
Some(api) => {
|
||||
parse_origin("api_url", api, true)?;
|
||||
api.trim().trim_end_matches('/').to_string()
|
||||
}
|
||||
None => derive_api_url(&web_origin, web.host_str().unwrap_or_default()),
|
||||
};
|
||||
Ok(Self {
|
||||
web_url: web_origin,
|
||||
api_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Web origin without a trailing slash, e.g. `https://ghe.example.com`.
|
||||
#[must_use]
|
||||
pub fn web_url(&self) -> &str {
|
||||
&self.web_url
|
||||
}
|
||||
|
||||
/// REST API base without a trailing slash, e.g.
|
||||
/// `https://ghe.example.com/api/v3`.
|
||||
#[must_use]
|
||||
pub fn api_url(&self) -> &str {
|
||||
&self.api_url
|
||||
}
|
||||
|
||||
/// Authority of the web origin, e.g. `ghe.example.com` or
|
||||
/// `ghe.example.com:8443`.
|
||||
#[must_use]
|
||||
pub fn host(&self) -> &str {
|
||||
self.web_url
|
||||
.split_once("://")
|
||||
.map_or(self.web_url.as_str(), |(_, authority)| authority)
|
||||
}
|
||||
|
||||
/// The host name of the web origin, without any port.
|
||||
#[must_use]
|
||||
pub fn hostname(&self) -> &str {
|
||||
let authority = self.host();
|
||||
authority
|
||||
.rsplit_once(':')
|
||||
.map_or(authority, |(name, port)| {
|
||||
if port.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
name
|
||||
} else {
|
||||
authority
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_github_com(&self) -> bool {
|
||||
self.host() == "github.com"
|
||||
}
|
||||
|
||||
/// Public page of a GitHub App; also the base of its install link.
|
||||
/// GitHub Enterprise Server serves it under `/github-apps/`.
|
||||
#[must_use]
|
||||
pub fn app_page_url(&self, slug: &str) -> String {
|
||||
let prefix = if self.is_github_com() || self.hostname().ends_with(".ghe.com") {
|
||||
"apps"
|
||||
} else {
|
||||
"github-apps"
|
||||
};
|
||||
format!("{}/{prefix}/{slug}", self.web_url)
|
||||
}
|
||||
|
||||
/// GraphQL endpoint derived from the REST API URL.
|
||||
#[must_use]
|
||||
pub fn graphql_url(&self) -> String {
|
||||
match self.api_url.strip_suffix("/api/v3") {
|
||||
Some(base) => format!("{base}/api/graphql"),
|
||||
None => format!("{}/graphql", self.api_url),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn repository_url(&self, owner: &str, repo: &str) -> String {
|
||||
format!("{}/{owner}/{repo}", self.web_url)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pull_request_url(&self, owner: &str, repo: &str, number: u64) -> String {
|
||||
format!("{}/pull/{number}", self.repository_url(owner, repo))
|
||||
}
|
||||
|
||||
/// Domain of GitHub's private noreply commit emails.
|
||||
#[must_use]
|
||||
pub fn noreply_email_domain(&self) -> String {
|
||||
format!("users.noreply.{}", self.hostname())
|
||||
}
|
||||
|
||||
/// Git config key that routes this host's HTTPS credentials to a helper.
|
||||
#[must_use]
|
||||
pub fn credential_helper_key(&self) -> String {
|
||||
format!("credential.{}.helper", self.web_url)
|
||||
}
|
||||
|
||||
/// Whether `origin` is a git remote URL on this host (HTTPS, HTTP, scp-like
|
||||
/// SSH or `ssh://`).
|
||||
#[must_use]
|
||||
pub fn is_origin(&self, origin: &str) -> bool {
|
||||
let host = self.host();
|
||||
let hostname = self.hostname();
|
||||
origin.starts_with(&format!("{}/", self.web_url))
|
||||
|| origin.starts_with(&format!("http://{host}/"))
|
||||
|| origin.starts_with(&format!("git@{hostname}:"))
|
||||
|| origin.starts_with(&format!("ssh://git@{hostname}/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_api_url(web_origin: &str, hostname: &str) -> String {
|
||||
if hostname == "github.com" {
|
||||
DEFAULT_API_URL.to_string()
|
||||
} else if hostname == "ghe.com" || hostname.ends_with(".ghe.com") {
|
||||
web_origin.replacen("://", "://api.", 1)
|
||||
} else {
|
||||
format!("{web_origin}/api/v3")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn host(web: &str) -> GitHubHost {
|
||||
GitHubHost::new(web, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_matches_github_com_constants() {
|
||||
let default = GitHubHost::default();
|
||||
assert_eq!(default.web_url(), "https://github.com");
|
||||
assert_eq!(default.api_url(), "https://api.github.com");
|
||||
assert_eq!(default, host("https://github.com"));
|
||||
assert!(default.is_github_com());
|
||||
assert_eq!(default.graphql_url(), "https://api.github.com/graphql");
|
||||
assert_eq!(default.repository_url("o", "r"), "https://github.com/o/r");
|
||||
assert_eq!(
|
||||
default.pull_request_url("o", "r", 7),
|
||||
"https://github.com/o/r/pull/7"
|
||||
);
|
||||
assert_eq!(default.noreply_email_domain(), "users.noreply.github.com");
|
||||
assert_eq!(
|
||||
default.credential_helper_key(),
|
||||
"credential.https://github.com.helper"
|
||||
);
|
||||
assert!(default.is_origin("git@github.com:o/r.git"));
|
||||
assert!(default.is_origin("https://github.com/o/r"));
|
||||
assert!(default.is_origin("http://github.com/o/r"));
|
||||
assert!(default.is_origin("ssh://git@github.com/o/r"));
|
||||
assert!(!default.is_origin("https://gitlab.com/o/r"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_residency_host_derives_urls() {
|
||||
let ghec = host("https://octocorp.ghe.com");
|
||||
assert_eq!(ghec.api_url(), "https://api.octocorp.ghe.com");
|
||||
assert_eq!(ghec.graphql_url(), "https://api.octocorp.ghe.com/graphql");
|
||||
assert_eq!(ghec.host(), "octocorp.ghe.com");
|
||||
assert!(!ghec.is_github_com());
|
||||
assert_eq!(
|
||||
ghec.repository_url("o", "r"),
|
||||
"https://octocorp.ghe.com/o/r"
|
||||
);
|
||||
assert_eq!(
|
||||
ghec.pull_request_url("o", "r", 7),
|
||||
"https://octocorp.ghe.com/o/r/pull/7"
|
||||
);
|
||||
assert_eq!(
|
||||
ghec.noreply_email_domain(),
|
||||
"users.noreply.octocorp.ghe.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ghec.credential_helper_key(),
|
||||
"credential.https://octocorp.ghe.com.helper"
|
||||
);
|
||||
assert!(ghec.is_origin("git@octocorp.ghe.com:o/r.git"));
|
||||
assert!(ghec.is_origin("ssh://git@octocorp.ghe.com/o/r"));
|
||||
assert!(!ghec.is_origin("https://github.com/o/r"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_host_derives_urls() {
|
||||
let ghes = host("https://ghe.example.com/");
|
||||
assert_eq!(ghes.web_url(), "https://ghe.example.com");
|
||||
assert_eq!(ghes.api_url(), "https://ghe.example.com/api/v3");
|
||||
assert_eq!(ghes.graphql_url(), "https://ghe.example.com/api/graphql");
|
||||
assert_eq!(ghes.host(), "ghe.example.com");
|
||||
assert_eq!(ghes.repository_url("o", "r"), "https://ghe.example.com/o/r");
|
||||
assert_eq!(
|
||||
ghes.pull_request_url("o", "r", 7),
|
||||
"https://ghe.example.com/o/r/pull/7"
|
||||
);
|
||||
assert_eq!(ghes.noreply_email_domain(), "users.noreply.ghe.example.com");
|
||||
assert_eq!(
|
||||
ghes.credential_helper_key(),
|
||||
"credential.https://ghe.example.com.helper"
|
||||
);
|
||||
assert!(ghes.is_origin("git@ghe.example.com:o/r.git"));
|
||||
assert!(ghes.is_origin("https://ghe.example.com/o/r"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_keeps_port_and_lowercases() {
|
||||
let ghes = host("https://GHE.Example.com:8443");
|
||||
assert_eq!(ghes.web_url(), "https://ghe.example.com:8443");
|
||||
assert_eq!(ghes.host(), "ghe.example.com:8443");
|
||||
assert_eq!(ghes.hostname(), "ghe.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_api_url_wins() {
|
||||
let ghes = GitHubHost::new(
|
||||
"https://ghe.example.com",
|
||||
Some("https://api.ghe.example.com/"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ghes.api_url(), "https://api.ghe.example.com");
|
||||
assert_eq!(ghes.graphql_url(), "https://api.ghe.example.com/graphql");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_http_is_accepted() {
|
||||
let local = host("http://127.0.0.1:1234");
|
||||
assert_eq!(local.web_url(), "http://127.0.0.1:1234");
|
||||
assert_eq!(local.api_url(), "http://127.0.0.1:1234/api/v3");
|
||||
let mock = GitHubHost::new("http://127.0.0.1:1234", Some("http://127.0.0.1:1234")).unwrap();
|
||||
assert_eq!(mock.graphql_url(), "http://127.0.0.1:1234/graphql");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_values_name_the_problem() {
|
||||
let plain_http = GitHubHost::new("http://ghe.example.com", None).unwrap_err();
|
||||
assert!(
|
||||
plain_http.to_string().starts_with("web_url must use https"),
|
||||
"{plain_http}"
|
||||
);
|
||||
let bare = GitHubHost::new("ghe.example.com", None).unwrap_err();
|
||||
assert!(
|
||||
bare.to_string().starts_with("web_url must be an https URL"),
|
||||
"{bare}"
|
||||
);
|
||||
let path = GitHubHost::new("https://ghe.example.com/github", None).unwrap_err();
|
||||
assert!(
|
||||
path.to_string().contains("must not contain a path"),
|
||||
"{path}"
|
||||
);
|
||||
let api =
|
||||
GitHubHost::new("https://ghe.example.com", Some("ftp://ghe.example.com")).unwrap_err();
|
||||
assert_eq!(api.field(), "api_url");
|
||||
assert!(
|
||||
api.to_string().starts_with("api_url must use http"),
|
||||
"{api}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_never_echo_the_value() {
|
||||
for web in [
|
||||
"http://user:secret@ghe.example.com",
|
||||
"https://user:secret@ghe.example.com",
|
||||
"secret ghe.example.com",
|
||||
] {
|
||||
let error = GitHubHost::new(web, None).unwrap_err();
|
||||
assert!(!error.to_string().contains("secret"), "{error}");
|
||||
}
|
||||
let error = GitHubHost::new(
|
||||
"https://ghe.example.com",
|
||||
Some("ftp://user:secret@ghe.example.com"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(!error.to_string().contains("secret"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_url_accepts_plain_http_on_any_host() {
|
||||
let ghes = GitHubHost::new(
|
||||
"https://ghe.example.com",
|
||||
Some("http://github-mirror.corp:8080/"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ghes.api_url(), "http://github-mirror.corp:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_is_kept_for_http_urls_only() {
|
||||
let ghes = host("https://ghe.example.com:8443");
|
||||
assert_eq!(ghes.noreply_email_domain(), "users.noreply.ghe.example.com");
|
||||
assert!(ghes.is_origin("git@ghe.example.com:o/r.git"));
|
||||
assert!(ghes.is_origin("ssh://git@ghe.example.com/o/r"));
|
||||
assert!(ghes.is_origin("https://ghe.example.com:8443/o/r"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_github_com_itself_derives_the_github_com_api() {
|
||||
let sub = host("https://foo.github.com");
|
||||
assert!(!sub.is_github_com());
|
||||
assert_eq!(sub.api_url(), "https://foo.github.com/api/v3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_page_url_follows_the_host_kind() {
|
||||
assert_eq!(
|
||||
GitHubHost::default().app_page_url("my-app"),
|
||||
"https://github.com/apps/my-app"
|
||||
);
|
||||
assert_eq!(
|
||||
host("https://octocorp.ghe.com").app_page_url("my-app"),
|
||||
"https://octocorp.ghe.com/apps/my-app"
|
||||
);
|
||||
assert_eq!(
|
||||
host("https://ghe.example.com").app_page_url("my-app"),
|
||||
"https://ghe.example.com/github-apps/my-app"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trips_and_validates() {
|
||||
let ghes = host("https://ghe.example.com");
|
||||
let json = serde_json::to_value(&ghes).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"web_url": "https://ghe.example.com",
|
||||
"api_url": "https://ghe.example.com/api/v3",
|
||||
})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<GitHubHost>(json).unwrap(), ghes);
|
||||
assert!(
|
||||
serde_json::from_value::<GitHubHost>(
|
||||
serde_json::json!({"web_url": "http://x.example.com", "api_url": "https://x.example.com"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ pub mod diff;
|
|||
pub mod engine;
|
||||
pub mod failure_signature;
|
||||
pub mod git_identity;
|
||||
pub mod github_host;
|
||||
mod id;
|
||||
mod input_scalar;
|
||||
pub mod interview;
|
||||
|
|
@ -86,6 +87,7 @@ pub use diff::{DiffStats, DiffSummary, RunDiff};
|
|||
pub use engine::{PetriAdmission, PetriGraphRef};
|
||||
pub use failure_signature::FailureSignature;
|
||||
pub use git_identity::{GitIdentity, GitIdentitySource};
|
||||
pub use github_host::{GitHubHost, GitHubHostError};
|
||||
pub use input_scalar::{
|
||||
JsonScalarToTomlError, TomlScalarToJsonError, json_scalar_to_toml_value,
|
||||
toml_scalar_to_json_value,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use serde::de::Error as DeError;
|
|||
use serde::ser::SerializeStruct;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::GitHubHost;
|
||||
use crate::id::ulid_id;
|
||||
|
||||
ulid_id!(PullRequestCreationId);
|
||||
|
|
@ -60,18 +61,18 @@ impl PullRequestCreation {
|
|||
/// Minimal GitHub pull request reference stored on a workflow run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PullRequestLink {
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub number: u64,
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub number: u64,
|
||||
/// The URL GitHub serves the pull request at, on whichever host the
|
||||
/// server is configured for.
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
impl PullRequestLink {
|
||||
#[must_use]
|
||||
pub fn html_url(&self) -> String {
|
||||
format!(
|
||||
"https://github.com/{}/{}/pull/{}",
|
||||
self.owner, self.repo, self.number
|
||||
)
|
||||
self.html_url.clone()
|
||||
}
|
||||
|
||||
pub fn from_github_url(url: &str) -> Result<Self, String> {
|
||||
|
|
@ -88,7 +89,7 @@ impl Serialize for PullRequestLink {
|
|||
state.serialize_field("owner", &self.owner)?;
|
||||
state.serialize_field("repo", &self.repo)?;
|
||||
state.serialize_field("number", &self.number)?;
|
||||
state.serialize_field("html_url", &self.html_url())?;
|
||||
state.serialize_field("html_url", &self.html_url)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
|
@ -115,34 +116,33 @@ impl<'de> Deserialize<'de> for PullRequestLink {
|
|||
let (Some(owner), Some(repo), Some(number)) = (wire.owner, wire.repo, wire.number) else {
|
||||
return Err(D::Error::custom("missing pull request owner/repo/number"));
|
||||
};
|
||||
let link = Self {
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
};
|
||||
|
||||
if let Some(html_url) = wire.html_url {
|
||||
let url_link =
|
||||
github_pull_request_link_from_url(&html_url).map_err(D::Error::custom)?;
|
||||
if url_link != link {
|
||||
return Err(D::Error::custom(
|
||||
"pull request html_url does not match owner/repo/number",
|
||||
));
|
||||
}
|
||||
// Records written before the link stored its URL are all github.com.
|
||||
let html_url = wire
|
||||
.html_url
|
||||
.unwrap_or_else(|| GitHubHost::default().pull_request_url(&owner, &repo, number));
|
||||
let url_link = github_pull_request_link_from_url(&html_url).map_err(D::Error::custom)?;
|
||||
// GitHub owner and repository names are case-insensitive.
|
||||
if !url_link.owner.eq_ignore_ascii_case(&owner)
|
||||
|| !url_link.repo.eq_ignore_ascii_case(&repo)
|
||||
|| url_link.number != number
|
||||
{
|
||||
return Err(D::Error::custom(
|
||||
"pull request html_url does not match owner/repo/number",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(link)
|
||||
Ok(url_link)
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "Pull request links are public github.com URLs stored for display and coordinate inference."
|
||||
reason = "Pull request links are public GitHub URLs stored for display and coordinate inference."
|
||||
)]
|
||||
fn github_pull_request_link_from_url(raw_url: &str) -> Result<PullRequestLink, String> {
|
||||
let parsed =
|
||||
url::Url::parse(raw_url).map_err(|err| format!("Invalid pull request URL: {err}"))?;
|
||||
if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") {
|
||||
if parsed.scheme() != "https" || parsed.host_str().is_none() {
|
||||
return Err(
|
||||
"Pull request link must be a GitHub pull request URL like https://github.com/owner/repo/pull/123."
|
||||
.to_string(),
|
||||
|
|
@ -168,6 +168,7 @@ fn github_pull_request_link_from_url(raw_url: &str) -> Result<PullRequestLink, S
|
|||
owner: (*owner).to_string(),
|
||||
repo: (*repo).to_string(),
|
||||
number,
|
||||
html_url: raw_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -325,11 +326,12 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pull_request_link_serializes_computed_html_url() {
|
||||
fn pull_request_link_serializes_its_html_url() {
|
||||
let link = PullRequestLink {
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 270,
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
number: 270,
|
||||
html_url: "https://github.com/fabro-sh/fabro/pull/270".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -343,6 +345,27 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_link_owner_and_repo_match_html_url_case_insensitively() {
|
||||
let link = serde_json::from_value::<PullRequestLink>(json!({
|
||||
"owner": "Acme",
|
||||
"repo": "Widgets",
|
||||
"number": 7,
|
||||
"html_url": "https://github.com/acme/widgets/pull/7"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(link.number, 7);
|
||||
assert!(
|
||||
serde_json::from_value::<PullRequestLink>(json!({
|
||||
"owner": "Acme",
|
||||
"repo": "Other",
|
||||
"number": 7,
|
||||
"html_url": "https://github.com/acme/widgets/pull/7"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_link_rejects_extra_legacy_record_fields() {
|
||||
let result = serde_json::from_value::<PullRequestLink>(json!({
|
||||
|
|
@ -356,4 +379,51 @@ mod tests {
|
|||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_link_without_html_url_is_a_github_com_link() {
|
||||
let link = serde_json::from_value::<PullRequestLink>(json!({
|
||||
"owner": "fabro-sh",
|
||||
"repo": "fabro",
|
||||
"number": 270
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(link.html_url, "https://github.com/fabro-sh/fabro/pull/270");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_link_keeps_the_enterprise_url_it_was_given() {
|
||||
let value = json!({
|
||||
"owner": "acme",
|
||||
"repo": "widgets",
|
||||
"number": 7,
|
||||
"html_url": "https://ghe.example.com/acme/widgets/pull/7"
|
||||
});
|
||||
let link = serde_json::from_value::<PullRequestLink>(value.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
link.html_url(),
|
||||
"https://ghe.example.com/acme/widgets/pull/7"
|
||||
);
|
||||
assert_eq!(serde_json::to_value(&link).unwrap(), value);
|
||||
assert_eq!(
|
||||
PullRequestLink::from_github_url("https://octocorp.ghe.com/acme/widgets/pull/7")
|
||||
.unwrap()
|
||||
.owner,
|
||||
"acme"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_link_rejects_a_url_that_disagrees_with_its_coordinates() {
|
||||
let result = serde_json::from_value::<PullRequestLink>(json!({
|
||||
"owner": "acme",
|
||||
"repo": "widgets",
|
||||
"number": 8,
|
||||
"html_url": "https://ghe.example.com/acme/widgets/pull/7"
|
||||
}));
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ use std::str::FromStr;
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::GitHubHost;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryRef {
|
||||
pub name: String,
|
||||
|
|
@ -15,12 +17,13 @@ pub struct RepositoryRef {
|
|||
|
||||
impl RepositoryRef {
|
||||
pub fn from_origin_and_source(
|
||||
host: &GitHubHost,
|
||||
origin_url: Option<String>,
|
||||
source_directory: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: repository_name(origin_url.as_deref(), source_directory),
|
||||
provider: repository_provider(origin_url.as_deref()),
|
||||
provider: repository_provider(host, origin_url.as_deref()),
|
||||
origin_url,
|
||||
}
|
||||
}
|
||||
|
|
@ -65,10 +68,10 @@ impl GitHubRepositorySlug {
|
|||
&self.repo
|
||||
}
|
||||
|
||||
/// Canonical credential-free HTTPS URL for this GitHub repository.
|
||||
/// Canonical credential-free HTTPS URL for this repository on `host`.
|
||||
#[must_use]
|
||||
pub fn https_url(&self) -> String {
|
||||
format!("https://github.com/{self}")
|
||||
pub fn https_url(&self, host: &GitHubHost) -> String {
|
||||
host.repository_url(&self.owner, &self.repo)
|
||||
}
|
||||
|
||||
/// Whether `other` names the same repository owner, ignoring ASCII case.
|
||||
|
|
@ -264,24 +267,17 @@ pub enum RepositoryProvider {
|
|||
Unknown,
|
||||
}
|
||||
|
||||
fn repository_provider(origin_url: Option<&str>) -> RepositoryProvider {
|
||||
fn repository_provider(host: &GitHubHost, origin_url: Option<&str>) -> RepositoryProvider {
|
||||
let Some(origin) = origin_url.filter(|origin| !origin.trim().is_empty()) else {
|
||||
return RepositoryProvider::Unknown;
|
||||
};
|
||||
if is_github_origin(origin) {
|
||||
if host.is_origin(origin) {
|
||||
RepositoryProvider::Github
|
||||
} else {
|
||||
RepositoryProvider::Git
|
||||
}
|
||||
}
|
||||
|
||||
fn is_github_origin(origin: &str) -> bool {
|
||||
origin.starts_with("git@github.com:")
|
||||
|| origin.starts_with("https://github.com/")
|
||||
|| origin.starts_with("http://github.com/")
|
||||
|| origin.starts_with("ssh://git@github.com/")
|
||||
}
|
||||
|
||||
fn repository_name(origin_url: Option<&str>, source_directory: Option<&str>) -> String {
|
||||
origin_url
|
||||
.and_then(repository_name_from_origin)
|
||||
|
|
@ -331,6 +327,48 @@ fn path_basename(path: &str) -> Option<&str> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repository_provider_is_github_only_for_origins_on_the_host() {
|
||||
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let provider = |host: &GitHubHost, origin: &str| {
|
||||
RepositoryRef::from_origin_and_source(host, Some(origin.to_string()), None).provider
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
provider(&GitHubHost::default(), "git@github.com:acme/widgets.git"),
|
||||
RepositoryProvider::Github
|
||||
);
|
||||
assert_eq!(
|
||||
provider(
|
||||
&GitHubHost::default(),
|
||||
"https://ghe.example.com/acme/widgets"
|
||||
),
|
||||
RepositoryProvider::Git
|
||||
);
|
||||
assert_eq!(
|
||||
provider(&ghes, "https://ghe.example.com/acme/widgets"),
|
||||
RepositoryProvider::Github
|
||||
);
|
||||
assert_eq!(
|
||||
provider(&ghes, "ssh://git@ghe.example.com/acme/widgets"),
|
||||
RepositoryProvider::Github
|
||||
);
|
||||
assert_eq!(
|
||||
provider(&ghes, "https://github.com/acme/widgets"),
|
||||
RepositoryProvider::Git
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_follows_the_host() {
|
||||
let slug = GitHubRepositorySlug::try_new("acme/widgets").unwrap();
|
||||
let ghes = GitHubHost::new("https://octocorp.ghe.com", None).unwrap();
|
||||
assert_eq!(
|
||||
slug.https_url(&ghes),
|
||||
"https://octocorp.ghe.com/acme/widgets"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_slugs_preserve_exact_parts() {
|
||||
let cases = [
|
||||
|
|
@ -404,8 +442,11 @@ mod tests {
|
|||
fn slug_https_url_preserves_spelling_and_has_no_credentials() {
|
||||
let slug: GitHubRepositorySlug = "Fabro-SH/Keystone".parse().unwrap();
|
||||
|
||||
assert_eq!(slug.https_url(), "https://github.com/Fabro-SH/Keystone");
|
||||
assert!(!slug.https_url().contains('@'));
|
||||
assert_eq!(
|
||||
slug.https_url(&GitHubHost::default()),
|
||||
"https://github.com/Fabro-SH/Keystone"
|
||||
);
|
||||
assert!(!slug.https_url(&GitHubHost::default()).contains('@'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ use std::collections::HashMap;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DirtyStatus, GitContext, GitHubRepositorySlug, RunId, WorkflowVersionId, repository};
|
||||
use crate::{
|
||||
DirtyStatus, GitContext, GitHubHost, GitHubRepositorySlug, RunId, WorkflowVersionId, repository,
|
||||
};
|
||||
|
||||
/// A request to create a run from an immutable workflow version.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -73,6 +75,18 @@ pub struct GitRunTarget {
|
|||
}
|
||||
|
||||
impl GitRunTarget {
|
||||
/// Validates the coordinate and returns it in canonical form, for callers
|
||||
/// that do not need the operational Git projection (and so no host).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same as [`GitRunTarget::validate`].
|
||||
pub fn canonical(self) -> Result<Self, GitCoordinateValidationError> {
|
||||
// The host does not affect the canonical form.
|
||||
self.validate(&GitHubHost::default())
|
||||
.map(ValidatedGitRunTarget::into_target)
|
||||
}
|
||||
|
||||
/// Validates and canonicalizes this Git coordinate without resolving remote
|
||||
/// repository state.
|
||||
///
|
||||
|
|
@ -80,7 +94,10 @@ impl GitRunTarget {
|
|||
///
|
||||
/// Returns an error when the repository slug, branch, tag, or exact commit
|
||||
/// does not use the canonical grammar accepted for Git-backed runs.
|
||||
pub fn validate(self) -> Result<ValidatedGitRunTarget, GitCoordinateValidationError> {
|
||||
pub fn validate(
|
||||
self,
|
||||
host: &GitHubHost,
|
||||
) -> Result<ValidatedGitRunTarget, GitCoordinateValidationError> {
|
||||
let Self {
|
||||
repo,
|
||||
branch,
|
||||
|
|
@ -104,7 +121,7 @@ impl GitRunTarget {
|
|||
})
|
||||
.transpose()?;
|
||||
let git = GitContext {
|
||||
origin_url: repository.https_url(),
|
||||
origin_url: repository.https_url(host),
|
||||
branch: branch.clone(),
|
||||
sha: sha.clone(),
|
||||
dirty: DirtyStatus::Clean,
|
||||
|
|
@ -123,6 +140,18 @@ impl GitRunTarget {
|
|||
}
|
||||
|
||||
impl RunTarget {
|
||||
/// Validates the target and returns it in canonical form, for callers
|
||||
/// that do not need the operational Git projection (and so no host).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same as [`RunTarget::validate`].
|
||||
pub fn canonical(self) -> Result<Self, TargetValidationError> {
|
||||
// The host does not affect the canonical form.
|
||||
self.validate(&GitHubHost::default())
|
||||
.map(|validated| validated.target)
|
||||
}
|
||||
|
||||
/// The wire `kind` discriminator (`git`, `none`, or `folder`), for
|
||||
/// diagnostics.
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
|
|
@ -139,10 +168,10 @@ impl RunTarget {
|
|||
///
|
||||
/// Returns an error when a Git target's repository slug, branch, tag, or
|
||||
/// exact commit does not use the canonical grammar accepted for runs.
|
||||
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
|
||||
pub fn validate(self, host: &GitHubHost) -> Result<ValidatedRunTarget, TargetValidationError> {
|
||||
match self {
|
||||
Self::Git(target) => {
|
||||
let validated = target.validate().map_err(TargetValidationError::from)?;
|
||||
let validated = target.validate(host).map_err(TargetValidationError::from)?;
|
||||
Ok(ValidatedRunTarget {
|
||||
target: Self::Git(validated.target),
|
||||
git: Some(validated.git),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use std::net::SocketAddr;
|
|||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::SandboxProviderKind;
|
||||
use crate::{GitHubHost, SandboxProviderKind};
|
||||
|
||||
/// A structurally resolved `[server]` view for consumers.
|
||||
///
|
||||
|
|
@ -297,6 +297,9 @@ pub struct GithubIntegrationSettings {
|
|||
pub client_id: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
pub webhooks: Option<IntegrationWebhooksSettings>,
|
||||
/// The GitHub deployment (`web_url`/`api_url` on the wire).
|
||||
#[serde(flatten)]
|
||||
pub host: GitHubHost,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_types::{
|
||||
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
|
||||
GitHubHost, GitRunTarget, RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId,
|
||||
normalize_git_commit_sha,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -195,7 +196,7 @@ fn target_validation_normalizes_sha_without_network_resolution() {
|
|||
tag: Some("release/v1".to_string()),
|
||||
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||
})
|
||||
.validate()
|
||||
.validate(&GitHubHost::default())
|
||||
.unwrap();
|
||||
let git = validated
|
||||
.git
|
||||
|
|
@ -225,7 +226,7 @@ fn git_target_validation_carries_the_parsed_repository_proof() {
|
|||
tag: None,
|
||||
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||
}
|
||||
.validate()
|
||||
.validate(&GitHubHost::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(validated.repository().owner(), "Fabro-Sh");
|
||||
|
|
@ -238,7 +239,7 @@ fn git_target_validation_carries_the_parsed_repository_proof() {
|
|||
|
||||
#[test]
|
||||
fn run_intent_none_target_validates_without_a_git_projection() {
|
||||
let validated = RunTarget::None {}.validate().unwrap();
|
||||
let validated = RunTarget::None {}.validate(&GitHubHost::default()).unwrap();
|
||||
assert_eq!(validated.target, RunTarget::None {});
|
||||
assert_eq!(validated.git, None);
|
||||
}
|
||||
|
|
@ -249,7 +250,7 @@ fn run_intent_folder_target_is_preserved_for_provider_admission() {
|
|||
path: "/srv/fabro/workspaces/example".to_string(),
|
||||
};
|
||||
|
||||
let validated = target.clone().validate().unwrap();
|
||||
let validated = target.clone().validate(&GitHubHost::default()).unwrap();
|
||||
|
||||
assert_eq!(validated.target, target);
|
||||
assert_eq!(validated.git, None);
|
||||
|
|
@ -266,7 +267,7 @@ fn target_validation_rejects_invalid_grammar() {
|
|||
tag: tag.map(str::to_string),
|
||||
sha: sha.map(str::to_string),
|
||||
})
|
||||
.validate()
|
||||
.validate(&GitHubHost::default())
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -348,3 +349,21 @@ fn git_target_round_trips_all_branch_tag_sha_states() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_validation_derives_the_origin_from_the_configured_host() {
|
||||
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
|
||||
let validated = RunTarget::Git(GitRunTarget {
|
||||
repo: "acme/widgets".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
})
|
||||
.validate(&host)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
validated.git.unwrap().origin_url,
|
||||
"https://ghe.example.com/acme/widgets"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ import type { GithubIntegrationStrategy } from './github-integration-strategy';
|
|||
import type { IntegrationWebhooksSettings } from './integration-webhooks-settings';
|
||||
|
||||
export interface GithubIntegrationSettings {
|
||||
/**
|
||||
* Web origin of the GitHub deployment, without a trailing slash.
|
||||
*/
|
||||
'web_url': string;
|
||||
/**
|
||||
* REST API base URL of the GitHub deployment, without a trailing slash.
|
||||
*/
|
||||
'api_url': string;
|
||||
'enabled': boolean;
|
||||
'strategy': GithubIntegrationStrategy;
|
||||
'app_id': string | null;
|
||||
|
|
|
|||
|
|
@ -20,4 +20,8 @@
|
|||
export interface InstallPrefill {
|
||||
'canonical_url': string;
|
||||
'object_store_local_root': string;
|
||||
/**
|
||||
* Web origin of the GitHub deployment the install targets, without a trailing slash.
|
||||
*/
|
||||
'github_web_url': string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue