Files
paseo/packages/app/src/git/client-forge-module.ts
T
Mohamed Boudra 85be2121b8 Link #123 issue and pull request references in chat (#8636)
* feat(app): link #123 issue and pull request references in Markdown

Agents cite issues and pull requests by number; reading one meant looking
up the URL by hand. Markdown in a workspace now links forge shorthand
against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and
cross-repository `owner/repo#N` on the same host.

Each forge's URL grammar declares which sigil maps to which path, so the
syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/,
which GitHub redirects to the pull request. Recognition goes through the
Markdown engine's text link regex so code spans, fences and authored links
stay plain without a second Markdown parse while streaming. The reference
alternative sits before path recognition so `group/project#12` is not read
as a file path.

* fix(app): keep http:// for self-hosted forge web links

A self-hosted forge cloned over http:// serves its web UI over http on
the same origin. Forcing https made reference, blob and tree links fail
against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
2026-11-03 13:00:24 +07:00

225 lines
8.0 KiB
TypeScript

import type { ComponentType } from "react";
import type { ReactNode } from "react";
import type { z } from "zod";
import type { CheckoutPrMergeMethod, CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import type { Theme } from "@/styles/theme";
import type { Forge } from "@/git/forge";
import type { PrPaneCheck } from "@/git/pull-request-panel/data";
export interface ForgeIconProps {
size?: number;
color?: string;
}
export type ForgeIconComponent = ComponentType<ForgeIconProps>;
export interface ForgeBrandColor {
light: string;
dark: string;
}
export type ForgeIconColorMapping = (theme: Theme) => { color: string };
export interface ForgeUrlGrammar {
/** Path infix before the branch in a tree URL, e.g. "/tree/", "/-/tree/". */
treeInfix: string;
/** Path infix before the branch in a blob URL, e.g. "/blob/", "/-/blob/". */
blobInfix: string;
/** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */
lineAnchor: (start: number, end?: number) => string;
/** Suffix for the change request's checks/pipelines overview. */
changeRequestChecksSuffix?: string;
/**
* Web-path infixes used when recognizing pasted links for this forge. The
* parser anchors each infix after the current remote's full repository path,
* so links for another repository are never auto-attached.
*/
referencePaths?: readonly ForgeReferencePath[];
}
export interface ForgeReferencePath {
kind: "change_request" | "issue";
/** Path between the repository path and the numeric local id. */
infix: string;
/**
* Shorthand marker the forge links in prose, e.g. GitHub `#12`, GitLab `!12`.
* Absent when the forge has no shorthand for this kind.
*/
sigil?: ForgeReferenceSigil;
}
export type ForgeReferenceSigil = "#" | "!";
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
}
/** URL grammar shared by the gitea-family forges (gitea, forgejo, codeberg). */
export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
treeInfix: "/src/branch/",
blobInfix: "/src/branch/",
lineAnchor: GITHUB_LINE_ANCHOR,
referencePaths: [
{ kind: "change_request", infix: "/pulls/", sigil: "!" },
{ kind: "issue", infix: "/issues/", sigil: "#" },
],
};
export type ForgeSpecificEnvelope = { forge: string } & Record<string, unknown>;
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
export type LegacyGithubMergeFacts = NonNullable<CheckoutPrStatus["github"]>;
export interface MergeCapability {
/** The change request can be merged directly right now. */
directMergeReady: boolean;
/** Auto-merge can be enabled right now. */
canEnableAutoMerge: boolean;
/** Auto-merge is already enabled on the change request. */
autoMergeEnabled: boolean;
/** The viewer is allowed to disable the active auto-merge. */
canDisableAutoMerge: boolean;
/** A merge queue is blocking both direct merge and auto-merge. */
mergeBlockedByQueue: boolean;
/** Merge methods the forge permits for this change request. */
allowedMethods: CheckoutPrMergeMethod[];
/** The forge's preferred/default merge method, if it reports one. */
preferredMethod: CheckoutPrMergeMethod | null;
}
export interface PaneChecksSlotContext {
serverId: string;
cwd: string;
/** Change request (PR/MR) number, so a section can address its head pipeline. */
changeRequestNumber: number;
open: boolean;
onToggle: () => void;
/** Whether the daemon advertises pluggable forge support (gates rich sections). */
enabled: boolean;
/**
* Whether the daemon can serve forge check/pipeline details over the
* forge-routed RPC. Single source of truth for gating on-demand detail
* fetches so a section never reaches an RPC the daemon lacks.
*/
canFetchCheckDetails: boolean;
}
export interface PaneNativeContribution {
guard: (facts: unknown) => boolean;
renderHeaderMeta: (facts: unknown) => ReactNode;
renderChecksSection: (facts: unknown, ctx: PaneChecksSlotContext) => ReactNode;
}
export interface NativeFallbackCheckEntry {
contribute: (facts: unknown, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
interface TypedPaneContribution<TFacts extends ForgeSpecificEnvelope> {
renderHeaderMeta?: (facts: TFacts) => ReactNode;
renderChecksSection?: (facts: TFacts, ctx: PaneChecksSlotContext) => ReactNode;
}
interface TypedNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope> {
contribute: (facts: TFacts, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
export interface ClientForgeFactsEntry<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
parse: (facts: unknown) => TFacts | null;
deriveMergeCapability: (facts: unknown) => MergeCapability | null;
readonly nativeFallbackChecks: readonly NativeFallbackCheckEntry[];
}
export interface ClientForgeFactsRegistration<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
readonly schema: z.ZodType<TFacts>;
readonly deriveMergeCapability?: (facts: TFacts) => MergeCapability;
readonly nativeFallbackChecks?: readonly NativeFallbackCheckEntry[];
}
function parseFacts<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
facts: unknown,
): TFacts | null {
if (!facts) {
return null;
}
const result = schema.safeParse(facts);
return result.success ? result.data : null;
}
export function defineNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedNativeFallbackCheck<TFacts>,
): NativeFallbackCheckEntry {
return {
contribute: (facts, status, forge) => {
const parsed = parseFacts(schema, facts);
return parsed ? contribution.contribute(parsed, status, forge) : null;
},
};
}
export function definePaneContribution<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedPaneContribution<TFacts>,
): PaneNativeContribution {
return {
guard: (facts) => parseFacts(schema, facts) !== null,
renderHeaderMeta: (facts) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderHeaderMeta ? contribution.renderHeaderMeta(parsed) : null;
},
renderChecksSection: (facts, ctx) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderChecksSection
? contribution.renderChecksSection(parsed, ctx)
: null;
},
};
}
export function defineForgeFacts<TFacts extends ForgeSpecificEnvelope>(
registration: ClientForgeFactsRegistration<TFacts>,
): ClientForgeFactsEntry<TFacts> {
return {
family: registration.family,
parse: (facts) => parseFacts(registration.schema, facts),
deriveMergeCapability: (facts) => {
if (!registration.deriveMergeCapability) {
return null;
}
const parsed = parseFacts(registration.schema, facts);
return parsed ? registration.deriveMergeCapability(parsed) : null;
},
nativeFallbackChecks: registration.nativeFallbackChecks ?? [],
};
}
/**
* Pure logic half of a forge: URL grammar and runtime-facts derivations. Kept
* free of any React/React-Native imports so logic consumers (URL builders,
* merge-capability, native-check fallbacks) — and the Node-based e2e harness
* that transitively imports them — never pull the client's rendering stack.
*/
export interface ClientForgeLogicModule<
TFacts extends ForgeSpecificEnvelope = ForgeSpecificEnvelope,
> {
readonly id: string;
readonly urlGrammar?: ForgeUrlGrammar;
readonly facts?: ClientForgeFactsEntry<TFacts>;
}
/**
* View half of a forge: the brand mark, brand color, and PR-pane render
* contributions. Imported only by rendering code (icon lookup, the PR pane).
*/
export interface ClientForgeViewModule {
readonly id: string;
readonly icon: ForgeIconComponent;
readonly brandColor?: ForgeBrandColor | null;
readonly paneContributions?: readonly PaneNativeContribution[];
}