@@ -128,7 +132,11 @@ export function PullRequestReviewBar({
size="sm"
className="mt-2"
value={body}
- placeholder="Summarize your review (optional)"
+ placeholder={
+ requestChangesSummaryRequired && verdicts.includes("request-changes")
+ ? "Summarize your review (required to request changes)"
+ : "Summarize your review (optional)"
+ }
aria-label="Review summary"
onChange={(event) => setSummary(reviewKey, event.target.value)}
/>
diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx
index 2acbd116fb..ed692a8137 100644
--- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx
@@ -7,6 +7,7 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-r
import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
+import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";
import { pullRequestEnvironment } from "~/state/pullRequests";
import { useEnvironmentQuery } from "~/state/query";
@@ -57,7 +58,10 @@ function EnabledPullRequestThreadLinks({
linking.mode === "multiple" && display !== "menu-item"
? pullRequestEnvironment.linkedThreads({
environmentId,
- input: parsed === null ? reference : { ...reference, ...parsed },
+ input:
+ parsed === null
+ ? reference
+ : { ...reference, ...normalizeThreadPullRequestKey(parsed) },
})
: null,
);
@@ -86,7 +90,10 @@ function EnabledPullRequestThreadLinks({
}
if (linking.mode === "multiple") {
appAtomRegistry.refresh(
- pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }),
+ pullRequestEnvironment.linkedThreads({
+ environmentId,
+ input: { ...reference, ...normalizeThreadPullRequestKey(parsed) },
+ }),
);
}
onPickerOpenChange?.(false);
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
index 9239d7560c..8990411772 100644
--- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
+++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
@@ -62,6 +62,7 @@ describe("pull request checkout commands", () => {
it.each([
["github", "feature", null, "gh pr checkout 42"],
["gitlab", "feature", null, "glab mr checkout 42"],
+ ["forgejo", "feature", null, null],
["azure-devops", "feature", null, "az repos pr checkout --id 42"],
[
"bitbucket",
@@ -73,7 +74,6 @@ describe("pull request checkout commands", () => {
] as const)("builds the %s command", (provider, branch, repository, expected) => {
expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected);
});
-
const reference = (host?: string): PullRequestRef => ({
projectId: ProjectId.make("project-1"),
...(host === undefined ? {} : { host }),
@@ -161,6 +161,54 @@ describe("pull request checkout commands", () => {
"git clone --single-branch --branch topic https://bitbucket.org/acme/web.git t3code-pr-42",
);
});
+
+ it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => {
+ expect(
+ pullRequestCheckoutCommand(
+ "forgejo",
+ 42,
+ "feature",
+ null,
+ "https://forgejo.local:3000/git/maria/repo",
+ ),
+ ).toBe(
+ "git fetch 'https://forgejo.local:3000/git/maria/repo' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
+ );
+ });
+ it("quotes shell metacharacters in Forgejo repository URLs", () => {
+ expect(
+ pullRequestCheckoutCommand(
+ "forgejo",
+ 42,
+ "feature",
+ null,
+ "https://forgejo.local/maria/repo'$(echo nope)",
+ ),
+ ).toBe(
+ "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
+ );
+ });
+ it("keeps Forgejo checkout unavailable until a trusted detail repository URL arrives", () => {
+ const forgejoReference = reference("forgejo.local:3000");
+ const summary = { provider: "forgejo" as const, number: 42, headBranch: "feature" };
+ expect(
+ panelPullRequestCheckoutCommand({
+ reference: forgejoReference,
+ identity: undefined,
+ summary,
+ }),
+ ).toBeNull();
+ expect(
+ panelPullRequestCheckoutCommand({
+ reference: forgejoReference,
+ identity: undefined,
+ summary,
+ repositoryUrl: "https://forgejo.local:3000/git/maria/repo",
+ }),
+ ).toBe(
+ "git fetch 'https://forgejo.local:3000/git/maria/repo' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
+ );
+ });
});
const TIMELINE_SOURCE: Pick<
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
index e8ed50fb29..b814b62a5e 100644
--- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
+++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
@@ -112,12 +112,17 @@ export function pullRequestCheckoutCommand(
number: number,
headBranch: string,
headRepositoryNameWithOwner?: string | null,
+ repositoryUrl?: string | null,
): string | null {
switch (provider) {
case "github":
return `gh pr checkout ${number}`;
case "gitlab":
return `glab mr checkout ${number}`;
+ case "forgejo":
+ return repositoryUrl
+ ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD`
+ : null;
case "azure-devops":
return `az repos pr checkout --id ${number}`;
case "bitbucket": {
@@ -157,6 +162,7 @@ export function panelPullRequestCheckoutCommand(input: {
readonly identity: RepositoryIdentity | null | undefined;
readonly summary: Pick
| null;
readonly headRepositoryNameWithOwner?: string | null | undefined;
+ readonly repositoryUrl?: string | null | undefined;
}): string | null {
return input.summary
? pullRequestCheckoutCommand(
@@ -164,6 +170,7 @@ export function panelPullRequestCheckoutCommand(input: {
input.summary.number,
input.summary.headBranch,
input.headRepositoryNameWithOwner,
+ input.repositoryUrl,
)
: loadingPullRequestCheckoutCommand(input.reference, input.identity);
}
diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts
index 1ccdb64b73..16c4fa5d35 100644
--- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts
+++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts
@@ -11,6 +11,7 @@ export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";
const OPEN_ON_HOST_LABELS: Partial> = {
github: "Open on GitHub",
gitlab: "Open on GitLab",
+ forgejo: "Open on Forgejo",
bitbucket: "Open on Bitbucket",
"azure-devops": "Open on Azure DevOps",
};
diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx
index 8b3796bcae..14af53fd12 100644
--- a/apps/web/src/components/settings/SourceControlSettings.tsx
+++ b/apps/web/src/components/settings/SourceControlSettings.tsx
@@ -52,6 +52,7 @@ import {
GitHubIcon,
GitIcon,
GitLabIcon,
+ ForgejoIcon,
JujutsuIcon,
type Icon,
} from "../Icons";
@@ -76,6 +77,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = {
github: GitHubIcon,
gitlab: GitLabIcon,
+ forgejo: ForgejoIcon,
"azure-devops": AzureDevOpsIcon,
bitbucket: BitbucketIcon,
};
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index cbbaa37746..3bc37fdf09 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -700,7 +700,7 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/source-control",
scope: "environment-defaults",
searchTerms: [
- "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",
+ "version control git github gitlab forgejo gitea tea codeberg bitbucket azure devops hosting integrations credentials scan server environment",
],
},
{
diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts
index 1e9dacf37f..538334dcc4 100644
--- a/apps/web/src/lib/openPullRequestLink.test.ts
+++ b/apps/web/src/lib/openPullRequestLink.test.ts
@@ -205,6 +205,37 @@ describe("matchesLinkedPullRequestUrl", () => {
).toBe(true);
});
+ it.each([
+ ["http://forge.example:3000/git/team/repo/pulls/42/files", true],
+ ["http://forge.example:4000/git/team/repo/pulls/42", false],
+ ["http://forge.example/git/team/repo/pulls/42", false],
+ ])("matches Forgejo links by web authority: %s", (url, expected) => {
+ expect(
+ matchesLinkedPullRequestUrl(
+ { ...linkedPullRequest, url: "http://forge.example:3000/git/team/repo/pulls/42" },
+ url,
+ ),
+ ).toBe(expected);
+ });
+
+ it("keeps other providers' existing port normalization", () => {
+ expect(
+ matchesLinkedPullRequestUrl(
+ linkedPullRequest,
+ "https://github.com:8443/pingdotgg/t3code/pull/42",
+ ),
+ ).toBe(true);
+ });
+
+ it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => {
+ expect(
+ matchesLinkedPullRequestUrl(
+ { ...linkedPullRequest, url: "https://github.internal/team/repo/pulls/42" },
+ "https://github.internal/team/repo/pull/42",
+ ),
+ ).toBe(false);
+ });
+
it("rejects a different pull request or host", () => {
expect(
matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"),
@@ -385,6 +416,59 @@ describe("findProjectOnChangeRequestHost", () => {
).toBe(backend);
});
+ it("selects the Forgejo HTTP port for repository and host-level matches", () => {
+ const projects = [3000, 4000].map((port) =>
+ project(`forgejo-${port}`, {
+ canonicalKey: "forge.example/git/team/repo",
+ provider: "forgejo",
+ displayName: "git/team/repo",
+ locator: { remoteUrl: `http://forge.example:${port}/git/team/repo.git` },
+ }),
+ );
+ const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!;
+ expect(findProjectForChangeRequest(projects, reference)).toBe(projects[1]);
+ expect(findProjectOnChangeRequestHost(projects, reference)).toBe(projects[1]);
+ expect(
+ findProjectOnChangeRequestHost(projects, { ...reference, repository: "git/team/other" }),
+ ).toBe(projects[1]);
+ expect(findProjectForChangeRequest([projects[0]!], reference)).toBeUndefined();
+ expect(findProjectOnChangeRequestHost([projects[0]!], reference)).toBeUndefined();
+ });
+
+ it("lets tea resolve the web port for Forgejo SSH remotes", () => {
+ const checkout = project("forgejo-ssh", {
+ canonicalKey: "forge.example/git/team/repo",
+ provider: "forgejo",
+ displayName: "git/team/repo",
+ locator: { remoteUrl: "git@forge.example:git/team/repo.git" },
+ });
+ const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!;
+ expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout);
+ const aliased = project("forgejo-alias", {
+ canonicalKey: "ssh.forge.example/team/repo",
+ provider: "forgejo",
+ displayName: "team/repo",
+ locator: { remoteUrl: "git@ssh.forge.example:team/repo.git" },
+ webUrl: "http://forge.example:4000/git/team/repo",
+ });
+ expect(findProjectForChangeRequest([aliased], reference)).toBe(aliased);
+ expect(findProjectOnChangeRequestHost([aliased], reference)).toBe(aliased);
+ expect(
+ findProjectOnChangeRequestHost([aliased], { ...reference, repository: "git/team/other" }),
+ ).toBe(aliased);
+ for (const url of [
+ "http://other.example:4000/git/team/repo/pulls/42",
+ "http://forge.example:3000/git/team/repo/pulls/42",
+ ]) {
+ const other = parseChangeRequestUrl(url)!;
+ expect(findProjectForChangeRequest([aliased], other)).toBeUndefined();
+ expect(findProjectOnChangeRequestHost([aliased], other)).toBeUndefined();
+ }
+ const otherMount = parseChangeRequestUrl("http://forge.example:4000/other/team/repo/pulls/42")!;
+ expect(findProjectForChangeRequest([aliased], otherMount)).toBeUndefined();
+ expect(findProjectOnChangeRequestHost([aliased], otherMount)).toBeUndefined();
+ });
+
it("lends any project on the host to a repository nobody has checked out", () => {
expect(
findProjectOnChangeRequestHost([frontend], {
diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts
index 4aea96b7d3..daf1b6a94d 100644
--- a/apps/web/src/lib/openPullRequestLink.ts
+++ b/apps/web/src/lib/openPullRequestLink.ts
@@ -30,13 +30,34 @@ export {
changeRequestRepositoryUrl,
} from "@t3tools/shared/changeRequestUrl";
-/**
- * Returns a click handler that opens a pull request URL in the system browser.
- *
- * Stops event propagation/default so activating the link does not also trigger
- * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a
- * toast when the local API is unavailable or the open fails.
- */
+function resolvedForgejoRepository(project: EnvironmentProject): URL | null {
+ const identity = project.repositoryIdentity;
+ if (identity?.provider !== "forgejo" || !identity.webUrl) return null;
+ try {
+ const url = new URL(identity.webUrl);
+ return url.protocol === "http:" || url.protocol === "https:" ? url : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Keep Forgejo servers on different HTTP ports separate when selecting a project. */
+function matchesChangeRequestAuthority(
+ project: EnvironmentProject,
+ link: ChangeRequestLink,
+): boolean {
+ if (link.authority === undefined) return true;
+ try {
+ const remote = new URL(project.repositoryIdentity?.locator.remoteUrl ?? "");
+ if (remote.protocol === "http:" || remote.protocol === "https:") {
+ return remote.host.toLowerCase() === link.authority;
+ }
+ } catch {
+ // SSH remotes do not specify the server's HTTP port; tea resolves the configured login.
+ }
+ return true;
+}
+
/**
* The project a link belongs to, or nothing. Matched the way the server matches: the repository
* identity is the full path below the host where one was recorded — which is what nested GitLab
@@ -49,9 +70,15 @@ export function findProjectForChangeRequest(
): EnvironmentProject | undefined {
return projects.find((project) => {
const identity = project.repositoryIdentity;
- if (!identity) return false;
+ if (!identity || !matchesChangeRequestAuthority(project, link)) return false;
const kind = identity.provider as SourceControlProviderKind | undefined;
if (kind === undefined) return false;
+ const web = resolvedForgejoRepository(project);
+ if (web)
+ return (
+ web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
+ web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase()
+ );
if (kind === "azure-devops") {
return (
canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
@@ -64,7 +91,8 @@ export function findProjectForChangeRequest(
return (
repository !== null &&
repository.toLowerCase() === link.repository.toLowerCase() &&
- pullRequestHostOf(identity, kind) === link.host.toLowerCase()
+ (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
+ pullRequestHostOf(identity, kind) === link.authority)
);
});
}
@@ -135,11 +163,25 @@ export function findProjectOnChangeRequestHost(
return projects.find((project) => {
const identity = project.repositoryIdentity;
const kind = identity?.provider as SourceControlProviderKind | undefined;
+ const web = resolvedForgejoRepository(project);
+ if (web) {
+ const mount = web.pathname
+ .replace(/^\/+|\/+$/g, "")
+ .split("/")
+ .slice(0, -2)
+ .join("/");
+ return (
+ web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
+ (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`))
+ );
+ }
return (
identity != null &&
kind !== undefined &&
kind !== "azure-devops" &&
- pullRequestHostOf(identity, kind) === link.host.toLowerCase()
+ matchesChangeRequestAuthority(project, link) &&
+ (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
+ pullRequestHostOf(identity, kind) === link.authority)
);
});
}
@@ -237,7 +279,7 @@ export function useOpenChangeRequestLink(
projectId: project.id,
...(serverConfigs.get(project.environmentId)?.environment.capabilities
.threadPullRequests === true
- ? { host: parsed.host }
+ ? { host: parsed.authority ?? parsed.host }
: {}),
repository,
url: targetUrl,
@@ -252,7 +294,7 @@ export function useOpenChangeRequestLink(
state: previous.state ?? "all",
repository,
number: parsed.number,
- selectedHost: parsed.host,
+ selectedHost: parsed.authority ?? parsed.host,
selectedProjectId: project.id,
selectedEnvironmentId: project.environmentId,
}),
@@ -270,7 +312,7 @@ export function useOpenChangeRequestLink(
state: "all",
repository,
number: parsed.number,
- selectedHost: parsed.host,
+ selectedHost: parsed.authority ?? parsed.host,
selectedProjectId: project.id,
// Named so the page opens the right one of two servers holding this project.
selectedEnvironmentId: project.environmentId,
diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts
index b919e736cc..421f60f036 100644
--- a/apps/web/src/pullRequestReference.ts
+++ b/apps/web/src/pullRequestReference.ts
@@ -1,3 +1,6 @@
+const FORGEJO_PULL_REQUEST_URL_PATTERN =
+ /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i;
+const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i;
const GITHUB_PULL_REQUEST_URL_PATTERN =
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
const GITLAB_MERGE_REQUEST_URL_PATTERN =
@@ -32,6 +35,7 @@ export function parsePullRequestReference(input: string): string | null {
const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);
const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
const normalizedInput =
+ FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ??
ghCliCheckoutMatch?.[1]?.trim() ??
glabCliCheckoutMatch?.[1]?.trim() ??
(azureDevOpsCliCheckoutMatch?.[1]
@@ -43,6 +47,7 @@ export function parsePullRequestReference(input: string): string | null {
}
const urlMatch =
+ FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??
AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts
index 94c4f1f2c1..4b6eb82d84 100644
--- a/apps/web/src/sourceControlPresentation.ts
+++ b/apps/web/src/sourceControlPresentation.ts
@@ -12,7 +12,13 @@ import {
resolveChangeRequestPresentation,
type ChangeRequestTerminology,
} from "@t3tools/shared/sourceControl";
-import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons";
+import {
+ AzureDevOpsIcon,
+ BitbucketIcon,
+ GitHubIcon,
+ GitLabIcon,
+ ForgejoIcon,
+} from "./components/Icons";
import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
export interface SourceControlPresentation {
@@ -32,6 +38,12 @@ export function getSourceControlPresentation(
terminology: getChangeRequestTerminology(provider),
Icon: GitHubIcon,
};
+ case "forgejo":
+ return {
+ providerName: provider?.name || presentation.providerName,
+ terminology: getChangeRequestTerminology(provider),
+ Icon: ForgejoIcon,
+ };
case "gitlab":
return {
providerName: provider?.name || presentation.providerName,
diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts
index 021fb255a4..efda37c144 100644
--- a/apps/web/src/state/sourceControlActions.ts
+++ b/apps/web/src/state/sourceControlActions.ts
@@ -269,7 +269,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
);
const action = useCallback(
async (input: {
- provider: "github" | "gitlab" | "bitbucket" | "azure-devops";
+ provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
repository: string;
visibility: SourceControlRepositoryVisibility;
remoteName: string;
diff --git a/docs/user/source-control.md b/docs/user/source-control.md
index 4dceb462e4..2675208a90 100644
--- a/docs/user/source-control.md
+++ b/docs/user/source-control.md
@@ -1,7 +1,7 @@
# Source control
-Pylon integrates with GitHub, GitLab, Bitbucket, and Azure DevOps to clone and publish repositories,
-create pull requests, and review changes.
+Pylon integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and
+publish repositories, create pull requests, and review changes.
## Connect an account
@@ -17,6 +17,26 @@ Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in:
gh auth login
```
+### Forgejo and Gitea
+
+Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or
+[Gitea CLI (`tea`)](https://gitea.com/gitea/tea) 0.16 or later on your Pylon server.
+Sign in with `fj --host https://your-server auth add-token` or `tea login add`.
+Repeat for each server you use, including Codeberg.
+
+Pylon prefers a matching `fj` login and falls back to `tea` when `fj` is unavailable
+or has no login for that server. Once an account is selected, failed actions stay on that
+account. Settings shows the detected CLI. Forgejo and Gitea share one integration entry.
+Servers hosted under a URL subpath, such as `https://example.com/forgejo`, use `tea` because
+fj 0.6 does not preserve the subpath when checking its account.
+
+When cloning or publishing, use a full repository URL to select a specific server.
+You can use `owner/repo` when only one fj server is configured, or with your default `tea`
+login when fj is unavailable or unconfigured. With multiple fj servers, use the full URL.
+If you have multiple `tea` accounts on one server, select one with
+`tea login default `. Git push and clone also need Git credentials or an SSH key
+for that server.
+
### GitLab
Install [GitLab CLI](https://gitlab.com/gitlab-org/cli), then sign in:
diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts
index cbbd121959..4b29e67a21 100644
--- a/packages/client-runtime/src/operations/projects.test.ts
+++ b/packages/client-runtime/src/operations/projects.test.ts
@@ -76,6 +76,13 @@ describe("add project shared logic", () => {
sshUrl: "git@github.com:imputnet/helium.git",
}),
).toBe("https://github.com/imputnet/helium");
+ expect(
+ getDefaultCloneUrl({
+ provider: "forgejo",
+ url: "https://forgejo.example.test:8443/owner/repo.git",
+ sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git",
+ }),
+ ).toBe("https://forgejo.example.test:8443/owner/repo.git");
});
it("preserves existing clone transport behavior for other providers", () => {
diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts
index 3f5fc3667f..597b0b5d7b 100644
--- a/packages/client-runtime/src/operations/projects.ts
+++ b/packages/client-runtime/src/operations/projects.ts
@@ -25,7 +25,7 @@ import type { EnvironmentProject } from "../state/models.ts";
export type AddProjectRemoteProviderKind = Extract<
SourceControlProviderKind,
- "github" | "gitlab" | "bitbucket" | "azure-devops"
+ "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
>;
export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
@@ -59,6 +59,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [
"url",
"github",
"gitlab",
+ "forgejo",
"bitbucket",
"azure-devops",
];
@@ -66,6 +67,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [
const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [
"github",
"gitlab",
+ "forgejo",
"bitbucket",
"azure-devops",
];
@@ -74,6 +76,8 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str
switch (source) {
case "github":
return "GitHub";
+ case "forgejo":
+ return "Forgejo / Gitea";
case "gitlab":
return "GitLab";
case "bitbucket":
@@ -87,6 +91,7 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str
export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
switch (source) {
+ case "forgejo":
case "github":
return "owner/repo";
case "gitlab":
@@ -117,11 +122,13 @@ export function normalizePastedCloneUrl(input: string): string {
return `https://github.com/${repository}`;
}
-/** GitHub defaults to HTTPS; other providers retain their existing SSH default. */
+/** GitHub and Forgejo default to HTTPS; other providers retain their existing SSH default. */
export function getDefaultCloneUrl(
repository: Pick,
): string {
- return repository.provider === "github" ? repository.url : repository.sshUrl;
+ return repository.provider === "github" || repository.provider === "forgejo"
+ ? repository.url
+ : repository.sshUrl;
}
export function sortAddProjectProviderSources(
@@ -153,6 +160,7 @@ export function buildAddProjectRemoteSourceReadiness(
url: { ready: true, hint: null },
github: unavailable,
gitlab: unavailable,
+ forgejo: unavailable,
bitbucket: unavailable,
"azure-devops": unavailable,
};
diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts
index 26a0c08c13..bcc6341b5c 100644
--- a/packages/client-runtime/src/state/pullRequests.ts
+++ b/packages/client-runtime/src/state/pullRequests.ts
@@ -102,6 +102,7 @@ export function createLinkedPullRequestSummaryAtomFamily(
return createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:linked-summary",
tag: WS_METHODS.pullRequestsSummary,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
staleTimeMs: 60_000,
refreshIntervalMs: 60_000,
idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
@@ -164,6 +165,7 @@ export function createPullRequestEnvironmentAtoms(
createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:detail",
tag: WS_METHODS.pullRequestsDetail,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
staleTimeMs: 60_000,
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
}),
@@ -194,6 +196,7 @@ export function createPullRequestEnvironmentAtoms(
list: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:list",
tag: WS_METHODS.pullRequestsList,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
staleTimeMs: 30_000,
refreshTrigger: ({ environmentId, input }) =>
input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined,
diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts
index 46cb9fa0f1..ca2e489424 100644
--- a/packages/client-runtime/src/state/runtime.ts
+++ b/packages/client-runtime/src/state/runtime.ts
@@ -614,6 +614,7 @@ export function createEnvironmentRpcQueryAtomFamily;
}) => Atom.Atom | undefined;
+ readonly transformInput?: (input: EnvironmentRpcInput) => EnvironmentRpcInput;
},
) {
return createEnvironmentQueryAtomFamily(runtime, {
@@ -624,7 +625,8 @@ export function createEnvironmentRpcQueryAtomFamily) => request(options.tag, input),
+ execute: (input: EnvironmentRpcInput) =>
+ request(options.tag, options.transformInput?.(input) ?? input),
});
}
@@ -639,6 +641,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
readonly label: string;
readonly tag: TTag;
readonly idleTtlMs?: number;
+ readonly transformInput?: (input: EnvironmentRpcInput) => EnvironmentRpcInput;
readonly transform?: (
stream: Stream.Stream<
EnvironmentRpcStreamValue,
@@ -652,7 +655,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
label: options.label,
...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }),
subscribe: (input: EnvironmentRpcInput) => {
- const stream = subscribe(options.tag, input);
+ const stream = subscribe(options.tag, options.transformInput?.(input) ?? input);
return options.transform === undefined
? (stream as Stream.Stream, EnvironmentSupervisor | R>)
: options.transform(stream);
@@ -684,6 +687,7 @@ export function createEnvironmentRpcCommand Effect.Effect;
+ readonly transformInput?: (input: EnvironmentRpcInput) => EnvironmentRpcInput;
},
) {
return createEnvironmentCommand(runtime, {
@@ -695,7 +699,7 @@ export function createEnvironmentRpcCommand options.onSuccess?.(target, registry) ?? Effect.void),
Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void),
);
diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts
index 39c7a95445..ef4c15e510 100644
--- a/packages/client-runtime/src/state/sourceControl.ts
+++ b/packages/client-runtime/src/state/sourceControl.ts
@@ -20,14 +20,17 @@ export function createSourceControlEnvironmentAtoms(
discovery: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:server:source-control-discovery",
tag: WS_METHODS.serverDiscoverSourceControl,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
}),
repository: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:source-control:repository",
tag: WS_METHODS.sourceControlLookupRepository,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
}),
cloneRepository: createEnvironmentRpcCommand(runtime, {
label: "environment-data:source-control:clone-repository",
tag: WS_METHODS.sourceControlCloneRepository,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
scheduler: commandScheduler,
concurrency: {
mode: "serial",
@@ -39,6 +42,7 @@ export function createSourceControlEnvironmentAtoms(
startProjectClone: createEnvironmentRpcCommand(runtime, {
label: "environment-data:source-control:project-clone-start",
tag: WS_METHODS.projectCloneStart,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
scheduler: commandScheduler,
concurrency: {
mode: "serial",
@@ -59,6 +63,7 @@ export function createSourceControlEnvironmentAtoms(
retryProjectClone: createEnvironmentRpcCommand(runtime, {
label: "environment-data:source-control:project-clone-retry",
tag: WS_METHODS.projectCloneRetry,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
scheduler: commandScheduler,
concurrency: {
mode: "serial",
@@ -70,6 +75,7 @@ export function createSourceControlEnvironmentAtoms(
projectClones: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
label: "environment-data:source-control:project-clones",
tag: WS_METHODS.subscribeProjectClones,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
}),
publishRepository: createEnvironmentRpcCommand(runtime, {
label: "environment-data:source-control:publish-repository",
diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts
index bedcb75121..79deb2c68d 100644
--- a/packages/client-runtime/src/state/vcs.ts
+++ b/packages/client-runtime/src/state/vcs.ts
@@ -284,7 +284,7 @@ export function createVcsEnvironmentAtoms(
label: "environment-data:vcs:status",
idleTtlMs: VCS_STATUS_IDLE_TTL_MS,
subscribe: (input: EnvironmentRpcInput) =>
- subscribe(WS_METHODS.subscribeVcsStatus, input).pipe(
+ subscribe(WS_METHODS.subscribeVcsStatus, { ...input, supportsForgejo: true }).pipe(
Stream.mapAccum(
() => null as VcsStatusResult | null,
(current, event) => {
@@ -304,6 +304,7 @@ export function createVcsEnvironmentAtoms(
refreshStatus: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:refresh-status",
tag: WS_METHODS.vcsRefreshStatus,
+ transformInput: (input) => ({ ...input, supportsForgejo: true }),
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
onSettled: invalidateRefs,
diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.test.ts b/packages/client-runtime/src/threadPullRequestCompatibility.test.ts
index 4ce682cca3..a8f444800a 100644
--- a/packages/client-runtime/src/threadPullRequestCompatibility.test.ts
+++ b/packages/client-runtime/src/threadPullRequestCompatibility.test.ts
@@ -19,6 +19,24 @@ const input = {
};
describe("thread pull request capability negotiation", () => {
+ it.each([true, false])(
+ "preserves Forgejo ports in a multi-link mutation: linked=%s",
+ (linked) => {
+ const mutation = planThreadPullRequestMutation({
+ ...input,
+ linked,
+ capabilities: { threadPullRequests: true },
+ reference: {
+ host: "forge.example",
+ repository: "team/repo",
+ number: 7,
+ url: "http://forge.example:3000/team/repo/pulls/7",
+ },
+ });
+ expect(mutation).toMatchObject({ input: { host: "forge.example:3000", number: 7 } });
+ },
+ );
+
it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])(
"does not dispatch when linking is unadvertised: %j",
(capabilities) => {
diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.ts b/packages/client-runtime/src/threadPullRequestCompatibility.ts
index b747e7c786..4a31f82ec0 100644
--- a/packages/client-runtime/src/threadPullRequestCompatibility.ts
+++ b/packages/client-runtime/src/threadPullRequestCompatibility.ts
@@ -4,6 +4,7 @@ import type {
ThreadId,
ThreadPullRequestKey,
} from "@t3tools/contracts";
+import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
type LinkingCapabilities = Pick<
ExecutionEnvironmentCapabilities,
@@ -36,20 +37,19 @@ export function planThreadPullRequestMutation({
legacyRepository?: string | undefined;
linked: boolean;
}) {
+ const key = normalizeThreadPullRequestKey(reference);
switch (threadPullRequestLinkMode(capabilities)) {
case "multiple":
return linked
? {
type: "thread.pull-request.link" as const,
- input: { threadId, ...reference, source: "manual" as const },
+ input: { threadId, ...reference, ...key, source: "manual" as const },
}
: {
type: "thread.pull-request.unlink" as const,
input: {
threadId,
- host: reference.host,
- repository: reference.repository,
- number: reference.number,
+ ...key,
},
};
case "single":
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 1df2d0b02c..38be20c4f0 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -225,6 +225,8 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;
export const RepositoryIdentity = Schema.Struct({
canonicalKey: TrimmedNonEmptyString,
locator: RepositoryIdentityLocator,
+ /** Repository browser URL resolved from the server's configured hosting account. */
+ webUrl: Schema.optionalKey(TrimmedNonEmptyString),
rootPath: Schema.optionalKey(TrimmedNonEmptyString),
displayName: Schema.optionalKey(TrimmedNonEmptyString),
provider: Schema.optionalKey(TrimmedNonEmptyString),
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index c5a5825e19..1ca0c8ddfe 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -102,6 +102,8 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type;
export const VcsStatusInput = Schema.Struct({
cwd: TrimmedNonEmptyStringSchema,
+ /** New clients opt into Forgejo provider metadata; absent keeps older decoders usable. */
+ supportsForgejo: Schema.optional(Schema.Boolean),
});
export type VcsStatusInput = typeof VcsStatusInput.Type;
diff --git a/packages/contracts/src/projectClone.ts b/packages/contracts/src/projectClone.ts
index e624ec17a7..3b541d31e5 100644
--- a/packages/contracts/src/projectClone.ts
+++ b/packages/contracts/src/projectClone.ts
@@ -50,7 +50,9 @@ export const ProjectCloneSnapshot = Schema.Struct({
});
export type ProjectCloneSnapshot = typeof ProjectCloneSnapshot.Type;
-export const ProjectCloneSubscribeInput = Schema.Struct({});
+export const ProjectCloneSubscribeInput = Schema.Struct({
+ supportsForgejo: Schema.optional(Schema.Boolean),
+});
export type ProjectCloneSubscribeInput = typeof ProjectCloneSubscribeInput.Type;
/** Every tracked clone on the environment. Sent first, then after every change. */
@@ -59,6 +61,7 @@ export type ProjectCloneListEvent = typeof ProjectCloneListEvent.Type;
export const ProjectCloneStartInput = Schema.Struct({
projectId: ProjectId,
+ supportsForgejo: Schema.optional(Schema.Boolean),
title: TrimmedNonEmptyString,
createdAt: IsoDateTime,
provider: Schema.optional(SourceControlProviderKind),
@@ -79,6 +82,7 @@ export type ProjectCloneStartResult = typeof ProjectCloneStartResult.Type;
export const ProjectCloneActionInput = Schema.Struct({
projectId: ProjectId,
+ supportsForgejo: Schema.optional(Schema.Boolean),
});
export type ProjectCloneActionInput = typeof ProjectCloneActionInput.Type;
diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts
index 599006994d..4e39e0dcad 100644
--- a/packages/contracts/src/pullRequest.test.ts
+++ b/packages/contracts/src/pullRequest.test.ts
@@ -1,12 +1,16 @@
import * as Schema from "effect/Schema";
import { describe, expect, it } from "vite-plus/test";
+import { VcsStatusInput } from "./git.ts";
+import { ThreadPullRequestLink } from "./orchestration.ts";
import {
PullRequestActionInput,
PullRequestCapabilities,
PullRequestListInput,
PullRequestListResult,
+ PullRequestRef,
PullRequestReviewerRequestInput,
+ pullRequestHostOf,
resolvePullRequestAuthorFilter,
} from "./pullRequest.ts";
@@ -14,6 +18,54 @@ const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);
const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);
const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);
const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
+const { supportsForgejo: _listCapability, ...oldListFields } = PullRequestListInput.fields;
+const { supportsForgejo: _statusCapability, ...oldStatusFields } = VcsStatusInput.fields;
+const { supportsForgejo: _refCapability, ...oldRefFields } = PullRequestRef.fields;
+const decodeOldListInput = Schema.decodeUnknownSync(Schema.Struct(oldListFields));
+const decodeOldStatusInput = Schema.decodeUnknownSync(Schema.Struct(oldStatusFields));
+const decodeOldRef = Schema.decodeUnknownSync(Schema.Struct(oldRefFields));
+const decodeOldDiscoveryInput = Schema.decodeUnknownSync(Schema.Struct({}));
+const decodeLegacyThreadLink = Schema.decodeUnknownSync(ThreadPullRequestLink);
+
+it("lets the prior server request schemas discard Forgejo capability fields", () => {
+ expect(
+ decodeOldListInput({
+ state: "open",
+ supportsForgejo: true,
+ }),
+ ).toEqual({ state: "open" });
+ expect(
+ decodeOldStatusInput({
+ cwd: "/repo",
+ supportsForgejo: true,
+ }),
+ ).toEqual({ cwd: "/repo" });
+ expect(
+ decodeOldRef({
+ projectId: "project-1",
+ repository: "team/app",
+ number: 3,
+ supportsForgejo: true,
+ }),
+ ).toEqual({ projectId: "project-1", repository: "team/app", number: 3 });
+ expect(decodeOldDiscoveryInput({ supportsForgejo: true })).toEqual({
+ supportsForgejo: true,
+ });
+});
+
+it("keeps an already-linked Forgejo thread decodable by the prior provider-neutral schema", () => {
+ const link = {
+ host: "code.example",
+ repository: "team/app",
+ number: 3,
+ url: "https://code.example/team/app/pulls/3",
+ source: "manual",
+ linkedAt: "2026-09-24T00:00:00Z",
+ snapshot: null,
+ stack: null,
+ };
+ expect(decodeLegacyThreadLink(link)).toMatchObject(link);
+});
const LIST_RESULT: PullRequestListResult = {
viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" },
@@ -65,6 +117,20 @@ const LIST_RESULT: PullRequestListResult = {
};
describe("PullRequestListResult", () => {
+ it("separates Forgejo HTTP ports while preserving other provider host identities", () => {
+ const identity = {
+ canonicalKey: "forge.example/team/repo",
+ locator: { remoteUrl: "http://forge.example:3000/team/repo.git" },
+ };
+ expect(pullRequestHostOf(identity, "forgejo")).toBe("forge.example:3000");
+ expect(pullRequestHostOf(identity, "gitlab")).toBe("forge.example");
+ expect(
+ pullRequestHostOf(
+ { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } },
+ "forgejo",
+ ),
+ ).toBe("forge.example");
+ });
/**
* The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record
* with an optional value, for one — fails as an interrupted request rather than as a schema
diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts
index 38727980af..32a52f2cde 100644
--- a/packages/contracts/src/pullRequest.ts
+++ b/packages/contracts/src/pullRequest.ts
@@ -540,6 +540,8 @@ export type PullRequestListCursors = typeof PullRequestListCursors.Type;
export const PullRequestListInput = Schema.Struct({
state: PullRequestListState,
+ /** New clients opt into Forgejo rows; absent keeps older decoders usable. */
+ supportsForgejo: Schema.optional(Schema.Boolean),
involvement: Schema.optional(PullRequestInvolvement),
filters: Schema.optional(PullRequestListFilters),
projectId: Schema.optional(ProjectId),
@@ -646,6 +648,8 @@ export type PullRequestListResult = typeof PullRequestListResult.Type;
*/
export const PullRequestRef = Schema.Struct({
projectId: ProjectId,
+ /** New clients opt into Forgejo detail and summary payloads. */
+ supportsForgejo: Schema.optional(Schema.Boolean),
host: Schema.optional(TrimmedNonEmptyString),
repository: TrimmedNonEmptyString,
number: PositiveInt,
@@ -1142,6 +1146,12 @@ const PROVIDER_REQUIREMENT: Partial<
"GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.",
unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
},
+ forgejo: {
+ missing:
+ "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.",
+ unauthenticated:
+ "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the Pylon server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.",
+ },
gitlab: {
missing:
"GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.",
@@ -1169,9 +1179,24 @@ const PROVIDER_REQUIREMENT: Partial<
* knows its hosts before the listing answers, and the two must agree on what they are called.
*/
export function pullRequestHostOf(
- identity: { readonly canonicalKey?: string | undefined } | null | undefined,
+ identity:
+ | {
+ readonly canonicalKey?: string | undefined;
+ readonly locator?: { readonly remoteUrl: string } | undefined;
+ }
+ | null
+ | undefined,
kind: SourceControlProviderKind,
): string {
+ if (kind === "forgejo") {
+ try {
+ const remote = new URL(identity?.locator?.remoteUrl ?? "");
+ if (remote.protocol === "http:" || remote.protocol === "https:")
+ return remote.host.toLowerCase();
+ } catch {
+ // SSH remotes retain their canonical host; the CLI resolves their web endpoint.
+ }
+ }
const host = identity?.canonicalKey?.split("/")[0]?.trim();
return host === undefined || host.length === 0 ? kind : host.toLowerCase();
}
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 5580d8a67a..7cd12cc706 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -894,7 +894,7 @@ const WsServerMutateProviderInstancesRpc = Rpc.make(WS_METHODS.serverMutateProvi
});
const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, {
- payload: Schema.Struct({}),
+ payload: Schema.Struct({ supportsForgejo: Schema.optional(Schema.Boolean) }),
success: SourceControlDiscoveryResult,
error: EnvironmentAuthorizationError,
});
diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts
index cc98fbc1bb..30537df86f 100644
--- a/packages/contracts/src/sourceControl.ts
+++ b/packages/contracts/src/sourceControl.ts
@@ -5,6 +5,7 @@ import { VcsDriverKind } from "./vcs.ts";
export const SourceControlProviderKind = Schema.Literals([
"github",
"gitlab",
+ "forgejo",
"azure-devops",
"bitbucket",
"unknown",
@@ -63,6 +64,7 @@ export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Typ
export const SourceControlRepositoryLookupInput = Schema.Struct({
provider: SourceControlProviderKind,
+ supportsForgejo: Schema.optional(Schema.Boolean),
repository: TrimmedNonEmptyString,
cwd: Schema.optional(TrimmedNonEmptyString),
});
@@ -70,6 +72,7 @@ export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryL
export const SourceControlCloneRepositoryInput = Schema.Struct({
provider: Schema.optional(SourceControlProviderKind),
+ supportsForgejo: Schema.optional(Schema.Boolean),
repository: Schema.optional(TrimmedNonEmptyString),
remoteUrl: Schema.optional(TrimmedNonEmptyString),
destinationPath: TrimmedNonEmptyString,
diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts
index 51c54ba01f..3979a4ce14 100644
--- a/packages/shared/src/changeRequestUrl.test.ts
+++ b/packages/shared/src/changeRequestUrl.test.ts
@@ -26,6 +26,15 @@ describe("parseChangeRequestUrl", () => {
});
});
+ it("reads Forgejo URLs even when the hostname contains github", () => {
+ expect(parseChangeRequestUrl("https://github.internal/team/repo/pulls/7")).toEqual({
+ host: "github.internal",
+ authority: "github.internal",
+ repository: "team/repo",
+ number: 7,
+ });
+ });
+
it("reads a supported GitHub host with a middle DNS label", () => {
expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({
host: "code.github.example.com",
@@ -92,7 +101,23 @@ describe("parseChangeRequestUrl", () => {
});
describe("siblingPullRequestUrl", () => {
+ it("recognizes Forgejo on custom HTTP hosts", () => {
+ expect(parseChangeRequestUrl("http://git.example.test:3000/team/repo/pulls/42/files")).toEqual({
+ host: "git.example.test",
+ authority: "git.example.test:3000",
+ repository: "team/repo",
+ number: 42,
+ });
+ });
it.each([
+ [
+ "http://git.example.test:3000/team/repo/pulls/42/files",
+ "http://git.example.test:3000/team/repo/pulls/43",
+ ],
+ [
+ "https://git.example.test/forgejo/team/repo/pulls/42/files",
+ "https://git.example.test/forgejo/team/repo/pulls/43",
+ ],
["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],
[
"https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs",
@@ -127,6 +152,29 @@ describe("siblingPullRequestUrl", () => {
});
describe("changeRequestUrlFor", () => {
+ it("preserves the origin when the Forgejo host already contains its port", () => {
+ expect(
+ changeRequestUrlFor(
+ "forgejo",
+ "forge.example:3000",
+ "team/repo",
+ 42,
+ "http://forge.example:3000/team/repo.git",
+ ),
+ ).toBe("http://forge.example:3000/team/repo/pulls/42");
+ });
+
+ it.each([
+ ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"],
+ ["https://forge.example:8443/git/owner/repo.git", "https://forge.example:8443"],
+ ["git@forge.example:git/owner/repo.git", "https://forge.example"],
+ ["http://other.example:3000/git/owner/repo.git", "https://forge.example"],
+ ])("preserves the matching Forgejo web origin from %s", (remoteUrl, origin) => {
+ expect(changeRequestUrlFor("forgejo", "forge.example", "git/owner/repo", 42, remoteUrl)).toBe(
+ `${origin}/git/owner/repo/pulls/42`,
+ );
+ });
+
it.each([
["ssh.dev.azure.com", "v3/org/project/web"],
["vs-ssh.visualstudio.com", "v3/org/project/web"],
diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts
index 1a359f4d43..b106e8ac96 100644
--- a/packages/shared/src/changeRequestUrl.ts
+++ b/packages/shared/src/changeRequestUrl.ts
@@ -17,6 +17,8 @@ export interface ChangeRequestLink {
readonly host: string;
readonly repository: string;
readonly number: number;
+ /** Forgejo's HTTP host and port, separate from the portless repository identity. */
+ readonly authority?: string;
}
/** The host itself, one of its subdomains, or an install named after the provider. */
@@ -53,7 +55,13 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
// GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
if (isHostOf(host, "github.com", "github")) {
const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
- return claim(host, match);
+ if (match) return claim(host, match);
+ }
+ // Forgejo and Gitea use /pulls/ on arbitrary self-hosted domains.
+ const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname);
+ if (forgejo) {
+ const link = claim(host, forgejo);
+ return link === null ? null : { ...link, authority: url.host.toLowerCase() };
}
// GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
// separator is GitLab's own, so the hostname is not asked about.
@@ -87,10 +95,26 @@ export function changeRequestUrlFor(
host: string,
repository: string,
number: number,
+ remoteUrl?: string,
): string | null {
switch (kind) {
case "github":
return `https://${host}/${repository}/pull/${number}`;
+ case "forgejo": {
+ try {
+ const remote = new URL(remoteUrl ?? "");
+ if (
+ (remote.protocol === "http:" || remote.protocol === "https:") &&
+ (remote.hostname.toLowerCase() === host.toLowerCase() ||
+ remote.host.toLowerCase() === host.toLowerCase())
+ ) {
+ return `${remote.origin}/${repository}/pulls/${number}`;
+ }
+ } catch {
+ // SSH remotes do not specify the server's web origin.
+ }
+ return `https://${host}/${repository}/pulls/${number}`;
+ }
case "gitlab":
return `https://${host}/${repository}/-/merge_requests/${number}`;
case "bitbucket":
@@ -231,7 +255,8 @@ export function matchesLinkedPullRequestUrl(
target !== null &&
linked.host === target.host &&
linked.repository === target.repository &&
- linked.number === target.number
+ linked.number === target.number &&
+ linked.authority === target.authority
);
}
@@ -242,7 +267,7 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null {
const url = new URL(targetUrl);
const repositoryPath =
/^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
- /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
+ /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
url.pathname,
)?.[1];
if (!repositoryPath) return null;
@@ -256,7 +281,7 @@ export function siblingPullRequestUrl(url: string, number: number): string | nul
const reference = parseChangeRequestUrl(url);
if (reference === null || !Number.isSafeInteger(number) || number < 1) return null;
const sibling = new URL(url);
- const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec(
+ const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec(
sibling.pathname.slice(reference.repository.length + 1),
)?.[1];
if (route === undefined) return null;
diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts
index 6a60d6f9b3..f9c009fac0 100644
--- a/packages/shared/src/sourceControl.test.ts
+++ b/packages/shared/src/sourceControl.test.ts
@@ -59,6 +59,22 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
).toBe("bitbucket");
});
+ it("detects Forgejo and Gitea hosts while preserving HTTP origins", () => {
+ for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) {
+ expect(detectSourceControlProviderFromRemoteUrl(`http://${host}:3000/team/repo.git`)).toEqual(
+ {
+ kind: "forgejo",
+ name: "Forgejo",
+ baseUrl: `http://${host}:3000`,
+ },
+ );
+ }
+ expect(getChangeRequestTerminologyForKind("forgejo")).toEqual({
+ shortLabel: "PR",
+ singular: "pull request",
+ });
+ });
+
it("detects Azure DevOps SSH remotes", () => {
// The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host.
expect(
diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts
index 9f1dc384e7..0b2e0e2699 100644
--- a/packages/shared/src/sourceControl.ts
+++ b/packages/shared/src/sourceControl.ts
@@ -5,7 +5,7 @@ import type {
} from "@t3tools/contracts";
export interface ChangeRequestPresentation {
- readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
+ readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request";
readonly providerName: string;
readonly shortName: string;
readonly longName: string;
@@ -47,6 +47,17 @@ const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
urlExample: "https://gitlab.com/group/project/-/merge_requests/42",
};
+const FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
+ icon: "forgejo",
+ providerName: "Forgejo",
+ shortName: "PR",
+ longName: "pull request",
+ pluralLongName: "pull requests",
+ providerLongName: "Forgejo pull request",
+ checkoutCommandExample: "tea pr checkout 123",
+ urlExample: "https://codeberg.org/owner/repo/pulls/42",
+};
+
const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
icon: "azure-devops",
providerName: "Azure DevOps",
@@ -87,6 +98,8 @@ export function resolveChangeRequestPresentation(
return GITHUB_CHANGE_REQUEST_PRESENTATION;
case "gitlab":
return GITLAB_CHANGE_REQUEST_PRESENTATION;
+ case "forgejo":
+ return FORGEJO_CHANGE_REQUEST_PRESENTATION;
case "azure-devops":
return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;
case "bitbucket":
@@ -200,6 +213,20 @@ export function detectSourceControlProviderFromRemoteUrl(
}
const hostname = parseHostName(host);
+ if (
+ hostname === "codeberg.org" ||
+ hasDnsLabel(hostname, "forgejo") ||
+ hasDnsLabel(hostname, "gitea")
+ ) {
+ return {
+ kind: "forgejo",
+ name: "Forgejo",
+ baseUrl: /^https?:/iu.test(remoteUrl.trim())
+ ? new URL(remoteUrl.trim()).origin
+ : toBaseUrl(host),
+ };
+ }
+
if (isGitHubHost(hostname)) {
return {
kind: "github",
diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts
index 6f231f2898..4844df8f77 100644
--- a/packages/shared/src/threadPullRequests.test.ts
+++ b/packages/shared/src/threadPullRequests.test.ts
@@ -62,6 +62,28 @@ function link(
}
describe("threadPullRequestKeysEqual", () => {
+ it("recovers Forgejo ports from old stored URLs and keeps separate servers distinct", () => {
+ const old = link(1, {
+ host: "forge.example",
+ repository: "team/repo",
+ url: "http://forge.example:3000/team/repo/pulls/1",
+ });
+ expect(threadPullRequestKeysEqual(old, { ...old, host: "forge.example:3000" })).toBe(true);
+ expect(
+ threadPullRequestKeysEqual(old, {
+ host: "forge.example:3000",
+ repository: "team/repo",
+ number: 1,
+ }),
+ ).toBe(true);
+ expect(
+ threadPullRequestKeysEqual(old, {
+ ...old,
+ url: "http://forge.example:4000/team/repo/pulls/1",
+ }),
+ ).toBe(false);
+ });
+
it("ignores host and repository case", () => {
expect(
threadPullRequestKeysEqual(
diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts
index 55e2302783..e747c151bd 100644
--- a/packages/shared/src/threadPullRequests.ts
+++ b/packages/shared/src/threadPullRequests.ts
@@ -10,10 +10,23 @@ import { pullRequestHostOf } from "@t3tools/contracts";
import { parseChangeRequestUrl } from "./changeRequestUrl.ts";
import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts";
-/** Normalize stored link identity, including Azure's SSH and browser host aliases. */
-export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey {
+type ThreadPullRequestKeySource = ThreadPullRequestKey & {
+ readonly authority?: string;
+ readonly url?: string;
+};
+
+/** Normalize stored links, recovering Forgejo HTTP ports from old links' URLs. */
+export function normalizeThreadPullRequestKey(
+ key: ThreadPullRequestKeySource,
+): ThreadPullRequestKey {
+ const parsed = key.url === undefined ? null : parseChangeRequestUrl(key.url);
+ const authority =
+ key.authority ??
+ (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number
+ ? parsed.authority
+ : undefined);
const canonical = canonicalRepositoryKey(
- `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
+ `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
);
const separator = canonical.indexOf("/");
return {
@@ -31,7 +44,7 @@ export function legacyThreadPullRequestKey(
const parsed = parseChangeRequestUrl(linked.url);
if (parsed !== null && parsed.number === linked.number) {
const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`);
- if (canonical.startsWith("dev.azure.com/")) {
+ if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) {
return normalizeThreadPullRequestKey(parsed);
}
}
@@ -52,13 +65,13 @@ export function legacyThreadPullRequestKey(
/** Identity comparison for links: host-level, case-insensitive on host and repository. */
export function threadPullRequestKeysEqual(
- left: ThreadPullRequestKey,
- right: ThreadPullRequestKey,
+ left: ThreadPullRequestKeySource,
+ right: ThreadPullRequestKeySource,
): boolean {
return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right);
}
-export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string {
+export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string {
const normalized = normalizeThreadPullRequestKey(key);
return `${normalized.host}/${normalized.repository}#${normalized.number}`;
}
@@ -157,6 +170,20 @@ export function legacyLinkedPullRequestOf(
const key = legacyThreadPullRequestKey(link, link.host);
return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey;
}
+ const parsed = parseChangeRequestUrl(link.url);
+ if (parsed?.authority !== undefined) {
+ try {
+ const remote = new URL(identity.locator.remoteUrl);
+ if (remote.protocol === "http:" || remote.protocol === "https:") {
+ return (
+ parsed.authority === remote.host && parsed.repository === repository.toLowerCase()
+ );
+ }
+ } catch {
+ // SSH web ports are resolved by the provider's configured login.
+ }
+ return parsed.host === host && parsed.repository === repository.toLowerCase();
+ }
return (
link.host.toLowerCase() === host.toLowerCase() &&
link.repository.toLowerCase() === repository.toLowerCase()
@@ -193,7 +220,8 @@ export function resolveThreadPullRequestChains(
const nativeStacks = new Map>();
for (const link of visible) {
if (link.stack === null) continue;
- const stackKey = `${link.host.toLowerCase()}/${link.repository.toLowerCase()}#stack:${link.stack.id}`;
+ const key = normalizeThreadPullRequestKey(link);
+ const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`;
const members = nativeStacks.get(stackKey) ?? [];
members.push(link);
nativeStacks.set(stackKey, members);
@@ -206,8 +234,10 @@ export function resolveThreadPullRequestChains(
}
const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link)));
- const branchKey = (link: ThreadPullRequestLink, branch: string) =>
- `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`;
+ const branchKey = (link: ThreadPullRequestLink, branch: string) => {
+ const key = normalizeThreadPullRequestKey(link);
+ return `${key.host}/${key.repository}:${branch}`;
+ };
// Reused head names cannot identify a parent unambiguously.
const byHead = new Map();
for (const link of remaining) {