diff --git a/apps/fabro-web/app/components/automation-form.test.tsx b/apps/fabro-web/app/components/automation-form.test.tsx
index d2e89406d..c738536b4 100644
--- a/apps/fabro-web/app/components/automation-form.test.tsx
+++ b/apps/fabro-web/app/components/automation-form.test.tsx
@@ -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,
diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx
index bd9d3a8ce..6e8db8811 100644
--- a/apps/fabro-web/app/components/automation-form.tsx
+++ b/apps/fabro-web/app/components/automation-form.tsx
@@ -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]}`);
diff --git a/apps/fabro-web/app/install-app.test.tsx b/apps/fabro-web/app/install-app.test.tsx
index 99356a25c..5e8b30237 100644
--- a/apps/fabro-web/app/install-app.test.tsx
+++ b/apps/fabro-web/app/install-app.test.tsx
@@ -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(
+
+
+ } />
+
+ ,
+ );
+ });
+
+ 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;
diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx
index c8e6c8032..93b5a84bd 100644
--- a/apps/fabro-web/app/install-app.tsx
+++ b/apps/fabro-web/app/install-app.tsx
@@ -1148,6 +1148,7 @@ function GithubStep({
runStepSubmit: RunStepSubmit;
dispatchInstall: (action: InstallAction) => void;
}) {
+ const tokensUrl = `${session.prefill.github_web_url}/settings/tokens`;
return (
repo scope.
-
- github.com/settings/tokens
+
+ {tokensUrl.replace(/^https?:\/\//, "")}
diff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx
index 8a5059a28..cc3044af3 100644
--- a/apps/fabro-web/app/routes/automations-new.test.tsx
+++ b/apps/fabro-web/app/routes/automations-new.test.tsx
@@ -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: {
diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx
index ecc1b465c..a0751cbf0 100644
--- a/apps/fabro-web/app/routes/automations-new.tsx
+++ b/apps/fabro-web/app/routes/automations-new.tsx
@@ -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 (
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index 669b683e0..0092eb082 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -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.
diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs
index 527cb1658..b2b9341b2 100644
--- a/lib/apps/fabro-server/src/install.rs
+++ b/lib/apps/fabro-server/src/install.rs
@@ -796,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()
diff --git a/lib/packages/fabro-api-client/src/models/github-integration-settings.ts b/lib/packages/fabro-api-client/src/models/github-integration-settings.ts
index 7263955b8..128730497 100644
--- a/lib/packages/fabro-api-client/src/models/github-integration-settings.ts
+++ b/lib/packages/fabro-api-client/src/models/github-integration-settings.ts
@@ -21,12 +21,6 @@ import type { GithubIntegrationStrategy } from './github-integration-strategy';
import type { IntegrationWebhooksSettings } from './integration-webhooks-settings';
export interface GithubIntegrationSettings {
- 'enabled': boolean;
- 'strategy': GithubIntegrationStrategy;
- 'app_id': string | null;
- 'client_id': string | null;
- 'slug': string | null;
- 'webhooks': IntegrationWebhooksSettings | null;
/**
* Web origin of the GitHub deployment, without a trailing slash.
*/
@@ -35,4 +29,10 @@ export interface GithubIntegrationSettings {
* REST API base URL of the GitHub deployment, without a trailing slash.
*/
'api_url': string;
+ 'enabled': boolean;
+ 'strategy': GithubIntegrationStrategy;
+ 'app_id': string | null;
+ 'client_id': string | null;
+ 'slug': string | null;
+ 'webhooks': IntegrationWebhooksSettings | null;
}
diff --git a/lib/packages/fabro-api-client/src/models/install-prefill.ts b/lib/packages/fabro-api-client/src/models/install-prefill.ts
index 6ddc45ff7..10bb15cff 100644
--- a/lib/packages/fabro-api-client/src/models/install-prefill.ts
+++ b/lib/packages/fabro-api-client/src/models/install-prefill.ts
@@ -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;
}