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; }