Files
2026-07-13 13:32:57 +08:00

356 lines
11 KiB
TypeScript

import { GitMeta } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import { type z } from "zod";
import { type Prisma, type PrismaClient, prisma } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { type BranchesOptions } from "~/utils/branches";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { checkBranchLimit } from "~/services/upsertBranch.server";
import { devPresence } from "./DevPresence.server";
import { sortEnvironments } from "~/utils/environmentSort";
import {
type BranchableEnvironmentToken,
type BranchableEnvironmentType,
toBranchableEnvironmentType,
} from "~/utils/branchableEnvironment";
type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
export type Branch = Result["branches"][number];
const BRANCHES_PER_PAGE = 25;
/**
* Prisma `where` fragment that scopes the branches list by branch name, keyed by
* environment type. Spread it into the query's `where` (it contributes either a
* `branchName` constraint or a top-level `OR`).
*
* The default DEV branch is the root dev env, stored with `branchName: null`, so
* for DEVELOPMENT we always include the null-branchName root (and still match it
* when searching — hence the top-level `OR`, since a scalar field filter can't
* express "matches search OR is null"). PREVIEW only ever lists real branches, so
* its root (null) is excluded. Passing no `search` yields the "all branches of
* this type" fragment.
*/
function branchNameFilter(
envType: BranchableEnvironmentType,
search?: string
): Prisma.RuntimeEnvironmentWhereInput {
switch (envType) {
case "DEVELOPMENT":
return search
? { OR: [{ branchName: { contains: search, mode: "insensitive" } }, { branchName: null }] }
: {};
case "PREVIEW":
return search
? { branchName: { contains: search, mode: "insensitive" } }
: { branchName: { not: null } };
default:
throw new Error(`branchNameFilter: unsupported environment type "${envType}"`);
}
}
type Options = z.infer<typeof BranchesOptions>;
export type GitMetaLinks = {
/** The cleaned repository URL without any username/password */
repositoryUrl: string;
/** The branch name */
branchName: string;
/** Link to the specific branch */
branchUrl: string;
/** Link to the specific commit */
commitUrl: string;
/** Link to the pull request (if available) */
pullRequestUrl?: string;
/** The pull request number (if available) */
pullRequestNumber?: number;
/** The pull request title (if available) */
pullRequestTitle?: string;
/** Link to compare this branch with main */
compareUrl: string;
/** Shortened commit SHA (first 7 characters) */
shortSha: string;
/** Whether the branch has uncommitted changes */
isDirty: boolean;
/** The commit message */
commitMessage: string;
/** The commit author */
commitAuthor: string;
/** The git provider, e.g., `github` */
provider?: string;
source?: "trigger_github_app" | "github_actions" | "local";
ghUsername?: string;
ghUserAvatarUrl?: string;
};
export class BranchesPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
env,
showArchived = false,
search,
page = 1,
}: {
userId: User["id"];
projectSlug: Project["slug"];
env: BranchableEnvironmentToken;
} & Options) {
const project = await this.#prismaClient.project.findFirst({
select: {
id: true,
organizationId: true,
},
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
throw new Error("Project not found");
}
const envType = toBranchableEnvironmentType(env);
const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: project.id,
type: envType,
// The branchable parent is the root env (no parent). For dev that's
// derivable; for preview we trust the isBranchableEnvironment column.
...(envType === "DEVELOPMENT"
? { parentEnvironmentId: null, orgMember: { userId } }
: { isBranchableEnvironment: true }),
},
});
const hasFilters = !!showArchived || (search !== undefined && search !== "");
if (!branchableEnvironment) {
if (envType === "DEVELOPMENT") {
throw new Error("No branchable environment in development environment");
}
return {
branchableEnvironment: null,
currentPage: page,
totalPages: 0,
hasBranches: false,
branches: [],
hasFilters: false,
limits: {
used: 0,
limit: 0,
isAtLimit: true,
},
canPurchaseBranches: false,
extraBranches: 0,
branchPricing: null,
maxBranchQuota: 0,
planBranchLimit: 0,
};
}
const branchNameWhere = branchNameFilter(envType, search);
const orgMemberWhere = envType === "DEVELOPMENT" ? { orgMember: { userId } } : {};
const visibleCount = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
type: envType,
...branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
});
const limits = await checkBranchLimit({
prisma: this.#prismaClient,
organizationId: project.organizationId,
projectId: project.id,
userId,
type: envType,
});
const [currentPlan, plans] = await Promise.all([
getCurrentPlan(project.organizationId),
getPlans(),
]);
const canPurchaseBranches =
currentPlan?.v3Subscription?.plan?.limits.branches.canExceed === true;
const extraBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0;
const maxBranchQuota = currentPlan?.v3Subscription?.addOns?.branches?.quota ?? 0;
const planBranchLimit = currentPlan?.v3Subscription?.plan?.limits.branches.number ?? 0;
const branchPricing = plans?.addOnPricing.branches ?? null;
const branches = await this.#prismaClient.runtimeEnvironment.findMany({
select: {
id: true,
slug: true,
branchName: true,
parentEnvironmentId: true,
type: true,
archivedAt: true,
createdAt: true,
updatedAt: true,
git: true,
},
where: {
projectId: project.id,
type: envType,
...branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
orderBy: {
branchName: "asc",
},
skip: (page - 1) * BRANCHES_PER_PAGE,
take: BRANCHES_PER_PAGE,
});
const totalBranches = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
type: envType,
...branchNameFilter(envType),
...orgMemberWhere,
},
});
const branchesFiltered = branches
.filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null)
.map((branch) => ({
...branch,
git: processGitMetadata(branch.git),
branchName: branch.branchName ?? DEFAULT_DEV_BRANCH,
}));
const branchesWithActivity = await hydrateEnvsWithActivity(
userId,
project.id,
branchesFiltered
);
const branchesSorted = sortEnvironments(branchesWithActivity);
return {
branchableEnvironment,
currentPage: page,
totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE),
hasBranches: totalBranches > 0,
branches: branchesSorted,
hasFilters,
limits,
canPurchaseBranches,
extraBranches,
branchPricing,
maxBranchQuota,
planBranchLimit,
};
}
}
export async function hydrateEnvsWithActivity<
T extends { type: RuntimeEnvironmentType; id: string },
>(
userId: string,
projectId: string,
environments: T[]
): Promise<Array<T & { lastActivity: Date | undefined; isConnected: boolean | undefined }>> {
const recentDevBranchIds = await devPresence.getRecentBranchIds(userId, projectId);
const devEnvIds = environments
.filter((env) => env.type === "DEVELOPMENT" && recentDevBranchIds.has(env.id))
.map((env) => env.id);
const connectedMap = await devPresence.isConnectedMany(devEnvIds);
return environments.map((env) => {
if (env.type !== "DEVELOPMENT") {
return { ...env, lastActivity: undefined, isConnected: undefined };
}
const devHit = recentDevBranchIds.get(env.id);
const lastActivity = devHit === undefined ? undefined : devHit;
const isConnected = devHit === undefined ? undefined : (connectedMap.get(env.id) ?? false);
return { ...env, lastActivity, isConnected };
});
}
export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null {
if (!data) return null;
const parsed = GitMeta.safeParse(data);
if (!parsed.success) {
return null;
}
if (!parsed.data.remoteUrl) {
return null;
}
// Clean the remote URL by removing any username/password and ensuring it's a proper GitHub URL
const cleanRemoteUrl = (() => {
try {
const url = new URL(parsed.data.remoteUrl);
// Remove any username/password from the URL
url.username = "";
url.password = "";
// Ensure we're using https
url.protocol = "https:";
// Remove any trailing .git
return url.toString().replace(/\.git$/, "");
} catch (_e) {
// If URL parsing fails, try to clean it manually
return parsed.data.remoteUrl
.replace(/^git@github\.com:/, "https://github.com/")
.replace(/^https?:\/\/[^@]+@/, "https://")
.replace(/\.git$/, "");
}
})();
if (!parsed.data.commitRef || !parsed.data.commitSha) return null;
const shortSha = parsed.data.commitSha.slice(0, 7);
return {
repositoryUrl: cleanRemoteUrl,
branchName: parsed.data.commitRef,
branchUrl: `${cleanRemoteUrl}/tree/${parsed.data.commitRef}`,
commitUrl: `${cleanRemoteUrl}/commit/${parsed.data.commitSha}`,
pullRequestUrl: parsed.data.pullRequestNumber
? `${cleanRemoteUrl}/pull/${parsed.data.pullRequestNumber}`
: undefined,
pullRequestNumber: parsed.data.pullRequestNumber,
pullRequestTitle: parsed.data.pullRequestTitle,
compareUrl: `${cleanRemoteUrl}/compare/main...${parsed.data.commitRef}`,
shortSha,
isDirty: parsed.data.dirty ?? false,
commitMessage: parsed.data.commitMessage ?? "",
commitAuthor: parsed.data.commitAuthorName ?? "",
provider: parsed.data.provider,
source: parsed.data.source,
ghUsername: parsed.data.ghUsername,
ghUserAvatarUrl: parsed.data.ghUserAvatarUrl,
};
}