356 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|