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
35 changes: 29 additions & 6 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests";
import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate";
import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
Expand All @@ -55,6 +56,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider";
import {
ForgejoPullRequestSchema,
Expand Down Expand Up @@ -545,6 +547,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
return {
service: {
kind: "github",
// The GitHub provider's own lookup rule and template convention, which GitManager reads
// instead of the kind.
headBranchProbe: ({ headSelectors }) => ({
headSelectors: headSelectors.filter((selector) => !selector.includes(":")),
limit: 100,
}),

listChangeRequests: (input) =>
input.state === "open"
? execute({
Expand Down Expand Up @@ -745,7 +754,18 @@ function makeManager(input?: {
);
const layerSourceControlRegistry = Layer.effect(
SourceControlProviderRegistry.SourceControlProviderRegistry,
Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe(
Effect.gen(function* () {
// GitHub reads its PR template with git, which the fake gh cannot answer; give the fake
// the package's real reader over the test repository's git.
const git = yield* GitVcsDriver.GitVcsDriver;
return (
input?.sourceControlProvider ?? {
...fakeGitHubProvider,
readChangeRequestTemplate: ({ cwd, treeish }: { cwd: string; treeish: string }) =>
GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute),
}
);
}).pipe(
Effect.map((provider) =>
SourceControlProviderRegistry.SourceControlProviderRegistry.of({
resolveLink: (input) => provider.resolveLink?.(input),
Expand All @@ -769,9 +789,12 @@ function makeManager(input?: {
runForThread: () => Effect.succeed({ status: "no-script" as const }),
},
),
layerVcsDriver,
layerServerSettings,
).pipe(Layer.provideMerge(layerSourceControlRegistry), Layer.provideMerge(NodeServices.layer));
).pipe(
Layer.provideMerge(layerSourceControlRegistry),
Layer.provideMerge(layerVcsDriver),
Layer.provideMerge(NodeServices.layer),
);
// Built into the test's scope: the manager reads these stores after this returns.
const layerStores = Layer.merge(ProjectionStore.layer, ProjectStore.layer).pipe(
Layer.provideMerge(SqlitePersistence.layerMemory),
Expand Down Expand Up @@ -4232,7 +4255,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
};
const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
`https://forgejo.example/forgejo/${owner}/project.git`,
"forgejo",
{ repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl },
);
expect(repository).toBe(`${owner}/project`);
const context = {
Expand All @@ -4253,13 +4276,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
expect(
GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
"git@forgejo.example:maria/project.git",
"forgejo",
{ repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl },
),
).toBe("maria/project");
expect(
GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
"https://gitlab.example/group/maria/project.git",
"gitlab",
{},
),
).toBe("group/maria/project");
}),
Expand Down
92 changes: 40 additions & 52 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import {
ModelSelection,
type ProjectId,
SourceControlProviderError,
type SourceControlProviderKind,
type SourceControlWritingStyleSettings,
type ThreadId,
type ThreadPullRequestKey,
Expand Down Expand Up @@ -81,8 +80,8 @@ import { detachStackFrame } from "./detachStackFrame.ts";
import * as ServerSettings from "../serverSettings.ts";
import type { GitManagerServiceError } from "@t3tools/contracts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts";
import type { ChangeRequest } from "@t3tools/contracts";

export interface GitActionProgressReporter {
Expand Down Expand Up @@ -314,27 +313,20 @@ function resolvePullRequestWorktreeLocalBranchName(
return `t3code/pr-${pullRequest.number}/${suffix}`;
}

/** The repository's `owner/name` from a remote URL, as `provider` reads its remote paths. */
export function parseRepositoryNameWithOwnerFromRemoteUrl(
url: string | null,
providerKind?: ChangeRequest["provider"],
provider?: Pick<
SourceControlProvider.SourceControlProvider["Service"],
"repositoryNameFromRemoteUrl"
>,
): string | null {
const trimmed = url?.trim() ?? "";
if (trimmed.length === 0) {
return null;
}

const match =
/^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
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;
if (trimmed.length === 0) return null;
return (
provider?.repositoryNameFromRemoteUrl?.(trimmed) ??
SourceControlProvider.repositoryPathFromRemoteUrl(trimmed)
);
}

function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null {
Expand Down Expand Up @@ -625,24 +617,18 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string
};
}

// Without the owner selector, a bare branch name also lists same-named
// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full
// page and let matchesBranchHeadContext pick the right head. gh fetches up to
// 100 in one request, and GitHub prices a first:100 connection like first:1.
const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100;

// `gh pr list --head` filters on the head ref name alone and accepts anything, so an
// `owner:branch` or `remote:branch` selector silently lists zero pull requests
// while spending a GraphQL call. Git branch names cannot contain ":", and the
// bare head branch is always among the selectors, so GitHub probes skip them and
// leave the owner check to matchesBranchHeadContext.
function probeableHeadSelectors(
providerKind: SourceControlProviderKind,
/** The selectors and page size `provider` asks about when looking up a branch's change requests. */
function headBranchProbe(
provider: SourceControlProvider.SourceControlProvider["Service"],
headSelectors: ReadonlyArray<string>,
): ReadonlyArray<string> {
return providerKind === "github"
? headSelectors.filter((selector) => !selector.includes(":"))
: headSelectors;
state: "open" | "all",
) {
return (
provider.headBranchProbe?.({ headSelectors, state }) ?? {
headSelectors,
limit: state === "open" ? 1 : 20,
}
);
}

function appendUnique(values: string[], next: string | null | undefined): void {
Expand Down Expand Up @@ -1430,15 +1416,14 @@ export const make = Effect.gen(function* () {
/^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);
const kind = detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind;
const provider =
kind === undefined
? undefined
: yield* (
kind === "unknown" ? sourceControlProvider(cwd) : sourceControlProviders.get(kind)
).pipe(Effect.orElseSucceed(() => undefined));
repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, provider);
}
return {
remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
Expand Down Expand Up @@ -1701,13 +1686,13 @@ export const make = Effect.gen(function* () {
>,
) {
const provider = yield* sourceControlProvider(cwd);
const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors);
for (const headSelector of headSelectors) {
const probe = headBranchProbe(provider, headContext.headSelectors, "open");
for (const headSelector of probe.headSelectors) {
const pullRequests = yield* provider.listChangeRequests({
cwd,
headSelector,
state: "open",
limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1,
limit: probe.limit,
});
const normalizedPullRequests = pullRequests.map(toPullRequestInfo);

Expand All @@ -1733,12 +1718,13 @@ export const make = Effect.gen(function* () {
const parsedByNumber = new Map<number, PullRequestInfo>();

const provider = yield* sourceControlProvider(cwd);
for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) {
const probe = headBranchProbe(provider, headContext.headSelectors, "all");
for (const headSelector of probe.headSelectors) {
const pullRequests = yield* provider.listChangeRequests({
cwd,
headSelector,
state: "all",
limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20,
limit: probe.limit,
});

for (const pr of pullRequests.map(toPullRequestInfo)) {
Expand Down Expand Up @@ -2129,8 +2115,10 @@ export const make = Effect.gen(function* () {
const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
const policy = yield* resolveStylePolicy(cwd, settings);
const changeRequestTemplate =
settings.style.followChangeRequestTemplates && provider.kind === "github"
? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute))
settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate
? Option.getOrUndefined(
yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }),
)
: undefined;

const generated = yield* textGeneration.generatePrContent({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,7 @@ function bindProviderContext(
}

return SourceControlProvider.SourceControlProvider.of({
kind: provider.kind,
...(provider.resolveLink ? { resolveLink: provider.resolveLink } : {}),
...provider,
listChangeRequests: (input) =>
provider.listChangeRequests({
...input,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as Path from "effect/Path";
import * as ServerConfig from "../config.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import { detectPrTemplate } from "./PrTemplateDetection.ts";
import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate";
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";

const SINGLE_TEMPLATE_PATHS = [
Expand Down Expand Up @@ -80,7 +80,7 @@ const commitTemplates = (cwd: string) =>
const detectTemplate = (cwd: string, treeish = "HEAD") =>
Effect.gen(function* () {
const git = yield* GitVcsDriver.GitVcsDriver;
return yield* detectPrTemplate(cwd, treeish, git.execute);
return yield* GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute);
});

it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) =>
Expand Down
2 changes: 2 additions & 0 deletions packages/source-control-core/src/server/SourceControlHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export class SourceControlHost extends Context.Service<
readonly operation: string;
readonly cwd: string;
readonly args: ReadonlyArray<string>;
readonly maxOutputBytes?: number;
readonly appendTruncationMarker?: boolean;
}) => Effect.Effect<SourceControlGitOutput, GitCommandError>;
readonly resolveCommit: (input: {
readonly cwd: string;
Expand Down
39 changes: 39 additions & 0 deletions packages/source-control-core/src/server/SourceControlProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import type * as Option from "effect/Option";
import type {
ChangeRequest,
ChangeRequestState,
Expand Down Expand Up @@ -92,10 +93,48 @@ export function sourceControlRefFromInput(input: {
return input.source ?? parseSourceControlOwnerRef(input.headSelector);
}

/** The repository path a remote URL names (`owner/name`, or deeper for nested groups). */
export function repositoryPathFromRemoteUrl(url: string | null): string | null {
const trimmed = url?.trim() ?? "";
if (trimmed.length === 0) {
return null;
}

const match =
/^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
trimmed,
);
const path = match?.[1]?.trim() ?? "";
return path.length > 0 ? path : null;
}

export class SourceControlProvider extends Context.Service<
SourceControlProvider,
{
readonly kind: SourceControlProviderKind;
/**
* How to look up a branch's change requests: which head selectors to ask about, and how many
* results to read per selector. The caller checks the owner against what comes back, so a
* host that cannot search `owner:branch` can drop those selectors without losing anything.
* Absent asks about every selector, reading 1 for the open lookup and 20 for any state.
*/
readonly headBranchProbe?: (input: {
readonly headSelectors: ReadonlyArray<string>;
readonly state: "open" | "all";
}) => { readonly headSelectors: ReadonlyArray<string>; readonly limit: number };
/**
* The repository's change request template at `treeish`, for change request content
* generation to follow. Absent means the host has no template convention.
*/
readonly readChangeRequestTemplate?: (input: {
readonly cwd: string;
readonly treeish: string;
}) => Effect.Effect<Option.Option<string>>;
/**
* The repository's `owner/name` from a remote URL, for a host whose remote paths can carry
* more than that (Forgejo's HTTP installation mount). Absent means the whole path.
*/
readonly repositoryNameFromRemoteUrl?: (url: string) => string | null;
/** Optional capability for issue and change-request subjects. */
readonly resolveLink?: ResolveSourceControlLink;
readonly listChangeRequests: (input: {
Expand Down
Loading
Loading