Skip to content
17 changes: 16 additions & 1 deletion apps/mobile/src/components/SourceControlIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { withUniwind } from "uniwind";

const ThemedSvg = withUniwind(Svg);

export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops";
export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";

export function SourceControlIcon(props: {
readonly kind: SourceControlIconKind;
Expand All @@ -14,6 +14,21 @@ export function SourceControlIcon(props: {
const size = props.size ?? 18;

switch (props.kind) {
case "forgejo":
return (
<ThemedSvg
width={size}
height={size}
viewBox="0 0 24 24"
color={props.color}
colorClassName={props.colorClassName}
>
<Path
fill="currentColor"
d="M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z"
/>
</ThemedSvg>
);
case "github":
return (
<ThemedSvg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function AddProjectRepositoryRoute({
const title =
source === "github" ||
source === "gitlab" ||
source === "forgejo" ||
source === "bitbucket" ||
source === "azure-devops"
? addProjectRemoteSourceLabel(source)
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
source === "url" ||
source === "github" ||
source === "gitlab" ||
source === "forgejo" ||
source === "bitbucket" ||
source === "azure-devops"
) {
Expand Down
35 changes: 34 additions & 1 deletion apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { expect } from "vite-plus/test";
import type {
GitActionProgressEvent,
GitPreparePullRequestThreadInput,
SourceControlProviderInfo,
ThreadId,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -627,6 +628,7 @@ function preparePullRequestThread(
function makeManager(input?: {
ghScenario?: FakeGhScenario;
sourceControlProvider?: SourceControlProvider["Service"];
hostingProvider?: SourceControlProviderInfo;
textGeneration?: Partial<FakeGitTextGeneration>;
serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
Expand Down Expand Up @@ -673,7 +675,14 @@ function makeManager(input?: {
Effect.map((provider) =>
SourceControlProviderRegistry.SourceControlProviderRegistry.of({
get: () => Effect.succeed(provider),
resolveHandle: () => Effect.succeed({ provider, context: null }),
resolveHandle: ({ context }) =>
Effect.succeed({
provider,
context:
context && input?.hostingProvider
? { ...context, provider: input.hostingProvider }
: null,
}),
resolve: () => Effect.succeed(provider),
discover: Effect.succeed([]),
}),
Expand Down Expand Up @@ -712,6 +721,27 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe(
);

it.layer(GitManagerTestLayer)("GitManager", (it) => {
it.effect("status reports the discovered Forgejo web instance for a separate SSH host", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, [
"remote",
"add",
"origin",
"ssh://git@ssh.example.test:2222/Owner/Repo.git",
]);
const hostingProvider = {
kind: "forgejo",
name: "Forgejo",
baseUrl: "https://git.example.test:8443",
} as const;
const { manager } = yield* makeManager({ hostingProvider });
const status = yield* manager.status({ cwd: repoDir });
expect(status.sourceControlProvider).toEqual(hostingProvider);
}),
);

it.effect("status includes draft PR metadata when branch already has a draft PR", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -1645,6 +1675,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
"gitlab.example.com/group/subgroup/repository",
],
["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"],
["https://git.example.test/Owner/Repo/pulls/42", "git.example.test/owner/repo"],
["https://git.example.test/Forge/Owner/Repo/pulls/42", "git.example.test/Forge/owner/repo"],
["https://git.example.test:8443/Owner/Repo/pulls/42/files", "git.example.test:8443/owner/repo"],
[
"https://dev.azure.com/org/project/_git/repository/pullrequest/42",
"dev.azure.com/org/project/_git/repository",
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
import {
getChangeRequestTerminologyForKind,
isSshRemoteUrl,
normalizeSourceControlRepository,
type ChangeRequestTerminology,
} from "@t3tools/shared/sourceControl";

Expand Down Expand Up @@ -217,14 +218,18 @@ export function pullRequestRepositoryKey(value: string): string | null {
try {
const url = new URL(value);
const match =
/^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec(
/^(.*)\/(pull|pulls|-\/merge_requests|pull-requests|pullrequest)\/\d+(?:\/.*)?$/iu.exec(
url.pathname,
);
if (match?.[1] === undefined) return null;
url.pathname = match[1];
url.search = "";
url.hash = "";
return normalizeGitRemoteUrl(url.toString());
const key = normalizeGitRemoteUrl(url.toString());
// Forgejo's web authority identifies the instance independently of SSH.
return match[2]?.toLowerCase() === "pulls"
? `${url.host.toLowerCase()}/${normalizeSourceControlRepository(match[1].replace(/^\//u, ""), "forgejo")}`
: key;
} catch {
return null;
}
Expand Down Expand Up @@ -1262,7 +1267,15 @@ export const make = Effect.gen(function* () {
(yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
(yield* readConfigValueNullable(cwd, "remote.origin.url"));

return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
if (!remoteUrl || !provider || provider.kind !== "unknown") return provider;
const handle = yield* sourceControlProviders
.resolveHandle({
cwd,
context: { provider, remoteName: preferredRemoteName, remoteUrl },
})
Comment thread
Fryuni marked this conversation as resolved.
.pipe(Effect.orElseSucceed(() => null));
return handle?.context?.provider ?? provider;
});

const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,68 @@ describe("pull request toolkit handlers", () => {
}),
);

it.effect("links Forgejo URLs and repository inputs using the canonical web instance", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
project: makeProject({
canonicalKey: "forge.example.test:8443/owner/repo",
locator: {
source: "git-remote",
remoteName: "origin",
remoteUrl: "ssh://git@ssh.example.test:2222/Owner/Repo.git",
},
provider: "forgejo",
displayName: "Owner/Repo",
}),
});
const byRepository = yield* harness.call("link_pull_request", {
repository: "Owner/Repo",
number: 42,
});
expect(byRepository).toMatchObject({
host: "forge.example.test:8443",
repository: "owner/repo",
number: 42,
url: "https://forge.example.test:8443/owner/repo/pulls/42",
});
const byUrl = yield* harness.call("link_pull_request", {
url: "https://forge.example.test:9443/Owner/Repo/pulls/43",
});
expect(byUrl).toMatchObject({
host: "forge.example.test:9443",
repository: "owner/repo",
number: 43,
});
}),
);

it.effect("preserves an HTTP Forgejo origin for repository-and-number links", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
project: makeProject({
canonicalKey: "forge.example.test:3000/Forge/owner/repo",
locator: {
source: "git-remote",
remoteName: "origin",
remoteUrl: "http://forge.example.test:3000/Forge/Owner/Repo.git",
},
provider: "forgejo",
displayName: "Forge/Owner/Repo",
}),
});
const result = yield* harness.call("link_pull_request", {
repository: "Forge/Owner/Other",
number: 42,
});
expect(result).toMatchObject({
host: "forge.example.test:3000",
repository: "Forge/owner/other",
number: 42,
url: "http://forge.example.test:3000/Forge/owner/other/pulls/42",
});
}),
);

it.effect("rejects a target that names neither a URL nor repository and number", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ThreadPullRequestLink,
} from "@t3tools/contracts";
import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl";
import {
resolveThreadPullRequestChains,
threadPullRequestKeyOf,
Expand Down Expand Up @@ -81,14 +82,18 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
if (host === undefined) {
return yield* new PullRequestHostRequiredError({});
}
const repository = input.repository.toLowerCase();
const repository = normalizeSourceControlRepository(
input.repository,
host === projectHost.host ? projectHost.kind : undefined,
);
const url =
changeRequestUrlFor(
// The project's kind only describes its own host; another host gets no URL guess.
host === projectHost.host ? projectHost.kind : null,
host,
repository,
input.number,
project?.repositoryIdentity?.locator.remoteUrl,
) ?? `https://${host}/${repository}/pull/${input.number}`;
return { host, repository, number: input.number, url } satisfies ResolvedTarget;
});
Expand Down
35 changes: 19 additions & 16 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))(
it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull-requests-")))(
"OrchestrationProjectionPipeline pull request links",
(it) => {
it.effect("projects link, sync, unlink, legacy replay and delete into the link table", () =>
it.effect("projects Forgejo link lifecycle with a case-sensitive instance path", () =>
Effect.gen(function* () {
const projectionPipeline = yield* OrchestrationProjectionPipeline;
const eventStore = yield* OrchestrationEventStore;
Expand Down Expand Up @@ -746,7 +746,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
threadId,
projectId,
title: "Thread PR",
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
runtimeMode: "full-access",
branch: null,
worktreePath: null,
Expand Down Expand Up @@ -776,10 +779,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
payload: {
threadId,
link: {
host: "github.com",
repository: "pingdotgg/t3code",
host: "git.example.test",
repository: "Forge/pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
url: "https://git.example.test/Forge/pingdotgg/t3code/pulls/42",
source: "created",
linkedAt: "2026-01-01T00:00:02.000Z",
snapshot: null,
Expand All @@ -801,8 +804,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
stackJson: null,
},
{
host: "github.com",
repository: "pingdotgg/t3code",
host: "git.example.test",
repository: "Forge/pingdotgg/t3code",
number: 42,
source: "created",
linkedAt: "2026-01-01T00:00:02.000Z",
Expand All @@ -828,8 +831,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
type: "thread.pull-request-synced",
payload: {
threadId,
host: "github.com",
repository: "pingdotgg/t3code",
host: "git.example.test",
repository: "Forge/pingdotgg/t3code",
number: 42,
snapshot,
stack: null,
Expand All @@ -841,8 +844,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
type: "thread.pull-request-synced",
payload: {
threadId,
host: "github.com",
repository: "pingdotgg/t3code",
host: "git.example.test",
repository: "Forge/pingdotgg/t3code",
number: 99,
snapshot,
stack: null,
Expand Down Expand Up @@ -879,8 +882,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
type: "thread.pull-request-unlinked",
payload: {
threadId,
host: "GitHub.COM",
repository: "PingDotGG/T3Code",
host: "Git.Example.Test",
repository: "Forge/PingDotGG/T3Code",
number: 42,
updatedAt: "2026-01-01T00:00:05.000Z",
},
Expand All @@ -896,10 +899,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
payload: {
threadId,
link: {
host: "github.com",
repository: "pingdotgg/t3code",
host: "git.example.test",
repository: "Forge/pingdotgg/t3code",
number: 43,
url: "https://github.com/pingdotgg/t3code/pull/43",
url: "https://git.example.test/Forge/pingdotgg/t3code/pulls/43",
source: "agent",
linkedAt: "2026-01-01T00:00:06.000Z",
snapshot: null,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
yield* projectionThreadPullRequestRepository.delete({
threadId: event.payload.threadId,
host: event.payload.host.toLowerCase(),
repository: event.payload.repository.toLowerCase(),
repository: event.payload.repository,
number: event.payload.number,
});
yield* projectionThreadRepository.upsert({
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/orchestration/ThreadPullRequestReactor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl";
import {
canonicalRepositoryKey,
sourceControlRepositorySelector,
Expand Down Expand Up @@ -42,7 +43,8 @@ function samePullRequest(
if (left == null || right === null) return left == null && right === null;
return (
left.projectId === right.projectId &&
left.repository.toLowerCase() === right.repository.toLowerCase() &&
normalizeSourceControlRepository(left.repository) ===
normalizeSourceControlRepository(right.repository) &&
left.number === right.number &&
left.url === right.url
);
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/orchestration/ThreadSettlementReactor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl";
import { CommandId } from "@t3tools/contracts";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -164,7 +165,8 @@ export const make = Effect.gen(function* () {
const matchesMerge =
mergedPullRequest !== null &&
reference.projectId === mergedPullRequest.projectId &&
reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() &&
normalizeSourceControlRepository(reference.repository) ===
normalizeSourceControlRepository(mergedPullRequest.repository) &&
reference.number === mergedPullRequest.number;
if (!matchesMerge && !projects.has(reference.projectId)) {
return yield* Effect.die(new Error("linked pull request project not found"));
Expand Down
Loading