Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions apps/mobile/src/components/SourceControlIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg";
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,19 @@ export function SourceControlIcon(props: {
const size = props.size ?? 18;

switch (props.kind) {
case "forgejo":
// Official two-color mark from https://forgejo.org/favicon.svg.
return (
<Svg width={size} height={size} viewBox="0 0 212 212">
<G transform="translate(6 6)" fill="none">
<Path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" stroke="#ff6600" strokeWidth={25} />
<Path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" stroke="#d40000" strokeWidth={25} />
<Circle cx={142} cy={20} r={18} stroke="#ff6600" strokeWidth={15} />
<Circle cx={142} cy={88} r={18} stroke="#d40000" strokeWidth={15} />
<Circle cx={58} cy={180} r={18} stroke="#d40000" strokeWidth={15} />
</G>
</Svg>
);
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 @@ -112,6 +112,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
source === "url" ||
source === "github" ||
source === "gitlab" ||
source === "forgejo" ||
source === "bitbucket" ||
source === "azure-devops"
) {
Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
import {
ForgejoPullRequestSchema,
toForgejoChangeRequest,
} from "../sourceControl/forgejoPullRequests.ts";
import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as ServerConfig from "../config.ts";
Expand All @@ -47,6 +51,7 @@ import * as ServerSettings from "../serverSettings.ts";
import * as GitManager from "./GitManager.ts";

const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema);

interface FakeGhScenario {
prListSequence?: string[];
Expand Down Expand Up @@ -4023,6 +4028,69 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
20_000,
);

it.effect("matches mounted Forgejo heads without confusing forks sharing a branch", () =>
Effect.gen(function* () {
for (const owner of ["maria", "reviewer"]) {
const mapped = toForgejoChangeRequest(
yield* decodeForgejoPullRequest({
number: 42,
title: "Greeting",
html_url: "https://forgejo.example/forgejo/maria/project/pulls/42",
state: "open",
merged: false,
base: {
ref: "main",
sha: "base",
repo: { full_name: "maria/project", owner: { login: "maria" } },
},
head: {
ref: "greeting",
sha: "head",
repo: { full_name: `${owner}/project`, owner: { login: owner } },
},
}),
);
const pr = {
...mapped,
isDraft: mapped.isDraft ?? false,
closedAt: mapped.closedAt ?? null,
mergedAt: mapped.mergedAt ?? null,
};
const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
`https://forgejo.example/forgejo/${owner}/project.git`,
"forgejo",
);
expect(repository).toBe(`${owner}/project`);
const context = {
headBranch: "greeting",
headRepositoryNameWithOwner: repository,
headRepositoryOwnerLogin: repository?.split("/")[0] ?? null,
isCrossRepository: owner !== "maria",
};
expect(GitManager.matchesBranchHeadContext(pr, context)).toBe(true);
expect(
GitManager.matchesBranchHeadContext(pr, {
...context,
headRepositoryNameWithOwner: "other/project",
headRepositoryOwnerLogin: "other",
}),
).toBe(false);
}
expect(
GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
"git@forgejo.example:maria/project.git",
"forgejo",
),
).toBe("maria/project");
expect(
GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
"https://gitlab.example/group/maria/project.git",
"gitlab",
),
).toBe("group/maria/project");
}),
);

it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () =>
Effect.sync(() => {
const headContext = {
Expand Down
60 changes: 57 additions & 3 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,10 @@ function resolvePullRequestWorktreeLocalBranchName(
return `t3code/pr-${pullRequest.number}/${suffix}`;
}

function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
export function parseRepositoryNameWithOwnerFromRemoteUrl(
url: string | null,
providerKind?: ChangeRequest["provider"],
): string | null {
const trimmed = url?.trim() ?? "";
if (trimmed.length === 0) {
return null;
Expand All @@ -295,6 +298,12 @@ function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string |
trimmed,
);
const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
// Forgejo HTTP paths can include an installation mount; its API always names owner/repo.
if (providerKind === "forgejo" && /^https?:\/\//iu.test(trimmed)) {
return repositoryNameWithOwner.length > 0
? repositoryNameWithOwner.split("/").slice(-2).join("/")
: null;
}
return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
}

Expand Down Expand Up @@ -1369,7 +1378,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?.kind !== "unknown") return provider;
const handle = yield* sourceControlProviders
.resolveHandle({
cwd,
context: { provider, remoteName: preferredRemoteName, remoteUrl },
})
.pipe(Effect.orElseSucceed(() => null));
return handle?.context?.provider ?? provider;
});

const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
Expand All @@ -1385,7 +1402,22 @@ export const make = Effect.gen(function* () {
}

const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
if (
remoteUrl !== null &&
/^https?:\/\//iu.test(remoteUrl) &&
(repositoryNameWithOwner?.split("/").length ?? 0) > 2
) {
const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl);
const kind =
detected?.kind === "unknown"
? yield* sourceControlProvider(cwd).pipe(
Effect.map((provider) => provider.kind),
Effect.orElseSucceed(() => undefined),
)
: detected?.kind;
repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind);
}
return {
remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
repositoryNameWithOwner,
Expand Down Expand Up @@ -1645,6 +1677,17 @@ export const make = Effect.gen(function* () {
const pullRequests = yield* provider.listChangeRequests({
cwd,
headSelector,
...(headContext.headRepositoryNameWithOwner
? {
source: {
refName: headContext.headBranch,
repository: headContext.headRepositoryNameWithOwner,
...(headContext.headRepositoryOwnerLogin
? { owner: headContext.headRepositoryOwnerLogin }
: {}),
},
}
: {}),
state: "open",
limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1,
});
Expand Down Expand Up @@ -1676,6 +1719,17 @@ export const make = Effect.gen(function* () {
const pullRequests = yield* provider.listChangeRequests({
cwd,
headSelector,
...(headContext.headRepositoryNameWithOwner
? {
source: {
refName: headContext.headBranch,
repository: headContext.headRepositoryNameWithOwner,
...(headContext.headRepositoryOwnerLogin
? { owner: headContext.headRepositoryOwnerLogin }
: {}),
},
}
: {}),
state: "all",
limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20,
});
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,40 @@ describe("pull request toolkit handlers", () => {
}),
);

it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
project: makeProject({
canonicalKey: "forge.example/git/owner/repo",
locator: {
source: "git-remote",
remoteName: "origin",
remoteUrl: "http://forge.example:3000/git/owner/repo.git",
},
provider: "forgejo",
displayName: "git/owner/repo",
}),
});
const result = yield* harness.call("link_pull_request", {
repository: "git/owner/repo",
number: 42,
});
expect(result).toEqual({
host: "forge.example:3000",
repository: "git/owner/repo",
number: 42,
url: "http://forge.example:3000/git/owner/repo/pulls/42",
alreadyLinked: false,
});
const other = yield* harness.call("link_pull_request", {
host: "other.example",
repository: "owner/repo",
number: 42,
});
expect(other.url).toBe("https://other.example/owner/repo/pull/42");
}),
);

it.effect("rejects a target that names neither a URL nor repository and number", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
Expand Down Expand Up @@ -385,6 +419,17 @@ describe("pull request toolkit handlers", () => {
}
}),
);
it("reports an older Forgejo link's HTTP port when listing thread links", () => {
const result = listThreadPullRequests(
makeThread([
makeLink(42, {
host: "forge.example",
url: "http://forge.example:3000/t3tools/t3code/pulls/42",
}),
]),
);
expect(result.pullRequests[0]?.host).toBe("forge.example:3000");
});

it.effect("fails cleanly when the token's thread no longer exists", () =>
Effect.gen(function* () {
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@t3tools/contracts";
import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
import {
normalizeThreadPullRequestKey,
resolveThreadPullRequestChains,
threadPullRequestKeyOf,
visibleThreadPullRequests,
Expand Down Expand Up @@ -71,7 +72,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
if (parsed === null) {
return yield* new PullRequestUrlInvalidError({});
}
return { ...parsed, url: input.url } satisfies ResolvedTarget;
return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget;
}
if (input.repository === undefined || input.number === undefined) {
return yield* new PullRequestTargetIncompleteError({});
Expand All @@ -89,8 +90,12 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
host,
repository,
input.number,
project?.repositoryIdentity?.locator.remoteUrl,
) ?? `https://${host}/${repository}/pull/${input.number}`;
return { host, repository, number: input.number, url } satisfies ResolvedTarget;
return {
...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }),
url,
} satisfies ResolvedTarget;
});

function entryOf(
Expand All @@ -108,7 +113,7 @@ function entryOf(
}
}
return {
host: link.host,
host: normalizeThreadPullRequestKey(link).host,
repository: link.repository,
number: link.number,
url: link.url,
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,39 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
assert.deepEqual(yield* readLinks(), []);
assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]);

// Older Forgejo rows stored a portless host; unlink by their URL's authority.
yield* eventStore.append({
...base("2026-01-01T00:00:05.100Z"),
type: "thread.pull-request-linked",
payload: {
threadId,
link: {
host: "forge.example",
repository: "team/repo",
number: 42,
url: "http://forge.example:3000/team/repo/pulls/42",
source: "agent",
linkedAt: "2026-01-01T00:00:05.100Z",
snapshot: null,
stack: null,
},
updatedAt: "2026-01-01T00:00:05.100Z",
},
});
yield* eventStore.append({
...base("2026-01-01T00:00:05.200Z"),
type: "thread.pull-request-unlinked",
payload: {
threadId,
host: "forge.example:3000",
repository: "team/repo",
number: 42,
updatedAt: "2026-01-01T00:00:05.200Z",
},
});
yield* projectionPipeline.bootstrap;
assert.deepEqual(yield* readLinks(), []);

// Deleting the thread clears whatever links it still had.
yield* eventStore.append({
...base("2026-01-01T00:00:06.000Z"),
Expand Down
Loading
Loading