feat(web): read the GitHub web URL from the API

This commit is contained in:
Dávid Balatoni 2026-10-25 01:22:38 +02:00
parent 9335e89c7d
commit eb1d137124
No known key found for this signature in database
10 changed files with 156 additions and 17 deletions

View file

@ -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,

View file

@ -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]}`);

View file

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

View file

@ -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>

View file

@ -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: {

View file

@ -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 (

View file

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

View file

@ -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()

View file

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

View file

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