mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-12 08:41:16 +00:00
feat(web): read the GitHub web URL from the API
This commit is contained in:
parent
9335e89c7d
commit
eb1d137124
10 changed files with 156 additions and 17 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 (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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